2026-06-25 14:51:58 +09:00
|
|
|
"use strict";
|
|
|
|
|
|
|
|
|
|
(function (global) {
|
|
|
|
|
const Snapshot = global.TarinaiSnapshot;
|
|
|
|
|
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_codec.js");
|
2026-06-25 17:43:24 +09:00
|
|
|
const EXPORT_PREFIX = "たり";
|
|
|
|
|
const RAW_CODEC = "生";
|
|
|
|
|
const DEFLATE_CODEC = "縮";
|
|
|
|
|
const BINARY_SCHEMA_VERSION = 1;
|
|
|
|
|
const FIELD_IDS = Object.freeze(["garden", "cage", "park"]);
|
|
|
|
|
const GROUND_IDS = Object.freeze(["soil", "concrete", "blanket", "foot_massage"]);
|
|
|
|
|
const ITEM_TYPE_IDS = Object.freeze([
|
|
|
|
|
"grass", "zunchi", "water", "grass_bed", "plushie", "nest_box", "duplicator", "sweet", "love_mochi", "fight_mochi",
|
|
|
|
|
"sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice",
|
|
|
|
|
"stone", "ball", "signboard", "ant_nest", "ant_corpse", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h",
|
|
|
|
|
"splat", "food", "bed", "genkotsu"
|
|
|
|
|
]);
|
|
|
|
|
const STRUCTURE_TYPE_IDS = new Set([3, 4]);
|
|
|
|
|
const SERVING_FOOD_TYPE_IDS = new Set([7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 31]);
|
|
|
|
|
const PIN_TYPE_IDS = new Set([26, 27]);
|
|
|
|
|
|
|
|
|
|
const textEncoder = new TextEncoder();
|
|
|
|
|
const textDecoder = new TextDecoder();
|
|
|
|
|
|
|
|
|
|
const SAVE_TEXT_BITS = 11;
|
|
|
|
|
const SAVE_TEXT_BASE = 1 << SAVE_TEXT_BITS;
|
2026-06-25 14:51:58 +09:00
|
|
|
const SAVE_TEXT_ALPHABET = (() => {
|
2026-06-25 17:43:24 +09:00
|
|
|
const chars = [];
|
|
|
|
|
const addRange = (from, to) => {
|
|
|
|
|
for (let code = from; code <= to && chars.length < SAVE_TEXT_BASE; code++) chars.push(String.fromCharCode(code));
|
|
|
|
|
};
|
|
|
|
|
// 正規化で分解されにくい仮名とCJK統合漢字だけを使う。
|
|
|
|
|
addRange(0x3041, 0x3096); // ひらがな
|
|
|
|
|
addRange(0x30A1, 0x30FA); // カタカナ
|
|
|
|
|
addRange(0x4E00, 0x9FFF); // 常用漢字を含むCJK統合漢字。必要数に達したら停止。
|
|
|
|
|
return chars.join("");
|
2026-06-25 14:51:58 +09:00
|
|
|
})();
|
|
|
|
|
const SAVE_TEXT_REVERSE = (() => {
|
|
|
|
|
const map = new Map();
|
2026-06-25 17:43:24 +09:00
|
|
|
let index = 0;
|
|
|
|
|
for (const ch of SAVE_TEXT_ALPHABET) map.set(ch, index++);
|
2026-06-25 14:51:58 +09:00
|
|
|
return map;
|
|
|
|
|
})();
|
2026-06-25 17:43:24 +09:00
|
|
|
if ([...SAVE_TEXT_ALPHABET].length !== SAVE_TEXT_BASE) throw new Error("save alphabet must contain 2048 Japanese chars");
|
2026-06-25 14:51:58 +09:00
|
|
|
|
2026-06-25 17:43:24 +09:00
|
|
|
function writeVaruintToArray(out, value = 0) {
|
|
|
|
|
let n = toSafeUInt(value);
|
|
|
|
|
while (n >= 128) {
|
|
|
|
|
out.push((n % 128) | 128);
|
|
|
|
|
n = Math.floor(n / 128);
|
|
|
|
|
}
|
|
|
|
|
out.push(n & 127);
|
|
|
|
|
}
|
|
|
|
|
function readVaruintFromArray(bytes, start = 0) {
|
|
|
|
|
let n = 0;
|
|
|
|
|
let mul = 1;
|
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
|
|
|
const byte = bytes[start + i];
|
|
|
|
|
if (byte == null) throw new Error("truncated Japanese save length");
|
|
|
|
|
n += (byte & 127) * mul;
|
|
|
|
|
if (!(byte & 128)) return { value: n, next: start + i + 1 };
|
|
|
|
|
mul *= 128;
|
|
|
|
|
}
|
|
|
|
|
throw new Error("invalid Japanese save length");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function jp2048Encode(bytes) {
|
|
|
|
|
const source = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
|
|
|
|
|
const packet = [];
|
|
|
|
|
writeVaruintToArray(packet, source.length);
|
|
|
|
|
for (const byte of source) packet.push(byte & 255);
|
2026-06-25 14:51:58 +09:00
|
|
|
let b = 0;
|
|
|
|
|
let n = 0;
|
|
|
|
|
let out = "";
|
2026-06-25 17:43:24 +09:00
|
|
|
for (const byte of packet) {
|
2026-06-25 14:51:58 +09:00
|
|
|
b |= (byte & 255) << n;
|
|
|
|
|
n += 8;
|
2026-06-25 17:43:24 +09:00
|
|
|
while (n >= SAVE_TEXT_BITS) {
|
|
|
|
|
out += SAVE_TEXT_ALPHABET[b & (SAVE_TEXT_BASE - 1)];
|
|
|
|
|
b >>= SAVE_TEXT_BITS;
|
|
|
|
|
n -= SAVE_TEXT_BITS;
|
2026-06-25 14:51:58 +09:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-25 17:43:24 +09:00
|
|
|
if (n > 0) out += SAVE_TEXT_ALPHABET[b & (SAVE_TEXT_BASE - 1)];
|
2026-06-25 14:51:58 +09:00
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 17:43:24 +09:00
|
|
|
function jp2048Decode(text) {
|
2026-06-25 14:51:58 +09:00
|
|
|
let b = 0;
|
|
|
|
|
let n = 0;
|
2026-06-25 17:43:24 +09:00
|
|
|
const packet = [];
|
|
|
|
|
for (const ch of String(text || "").trim()) {
|
|
|
|
|
const value = SAVE_TEXT_REVERSE.get(ch);
|
|
|
|
|
if (value == null) continue;
|
|
|
|
|
b |= value << n;
|
|
|
|
|
n += SAVE_TEXT_BITS;
|
|
|
|
|
while (n >= 8) {
|
|
|
|
|
packet.push(b & 255);
|
|
|
|
|
b >>= 8;
|
|
|
|
|
n -= 8;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const { value: length, next } = readVaruintFromArray(packet, 0);
|
|
|
|
|
if (packet.length < next + length) throw new Error("truncated Japanese save payload");
|
|
|
|
|
return new Uint8Array(packet.slice(next, next + length));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toSafeUInt(value = 0) {
|
|
|
|
|
const n = Math.floor(Number(value) || 0);
|
|
|
|
|
return Number.isFinite(n) && n > 0 ? Math.min(n, Number.MAX_SAFE_INTEGER) : 0;
|
|
|
|
|
}
|
|
|
|
|
function toSafeInt(value = 0) {
|
|
|
|
|
const n = Math.round(Number(value) || 0);
|
|
|
|
|
return Number.isFinite(n) ? Math.max(Number.MIN_SAFE_INTEGER, Math.min(Number.MAX_SAFE_INTEGER, n)) : 0;
|
|
|
|
|
}
|
|
|
|
|
function zigzagEncode(value = 0) {
|
|
|
|
|
const n = toSafeInt(value);
|
|
|
|
|
return n < 0 ? (-n * 2 - 1) : (n * 2);
|
|
|
|
|
}
|
|
|
|
|
function zigzagDecode(value = 0) {
|
|
|
|
|
const n = toSafeUInt(value);
|
|
|
|
|
return (n % 2) ? -((n + 1) / 2) : (n / 2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class BinaryWriter {
|
|
|
|
|
constructor() { this.bytes = []; }
|
|
|
|
|
u(value = 0) {
|
|
|
|
|
let n = toSafeUInt(value);
|
|
|
|
|
while (n >= 128) {
|
|
|
|
|
this.bytes.push((n % 128) | 128);
|
|
|
|
|
n = Math.floor(n / 128);
|
|
|
|
|
}
|
|
|
|
|
this.bytes.push(n & 127);
|
|
|
|
|
}
|
|
|
|
|
s(value = 0) { this.u(zigzagEncode(value)); }
|
|
|
|
|
b(value = 0) { this.bytes.push((Number(value) || 0) & 255); }
|
|
|
|
|
str(value = "") {
|
|
|
|
|
const bytes = textEncoder.encode(String(value || ""));
|
|
|
|
|
this.u(bytes.length);
|
|
|
|
|
for (const byte of bytes) this.bytes.push(byte);
|
|
|
|
|
}
|
|
|
|
|
out() { return new Uint8Array(this.bytes); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class BinaryReader {
|
|
|
|
|
constructor(bytes = []) { this.bytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []); this.pos = 0; }
|
|
|
|
|
ensure(count = 1) {
|
|
|
|
|
if (this.pos + count > this.bytes.length) throw new Error("truncated binary save");
|
|
|
|
|
}
|
|
|
|
|
u() {
|
|
|
|
|
let n = 0;
|
|
|
|
|
let mul = 1;
|
|
|
|
|
for (let i = 0; i < 10; i++) {
|
|
|
|
|
this.ensure(1);
|
|
|
|
|
const byte = this.bytes[this.pos++];
|
|
|
|
|
n += (byte & 127) * mul;
|
|
|
|
|
if (!(byte & 128)) return n;
|
|
|
|
|
mul *= 128;
|
2026-06-25 14:51:58 +09:00
|
|
|
}
|
2026-06-25 17:43:24 +09:00
|
|
|
throw new Error("invalid varuint in binary save");
|
|
|
|
|
}
|
|
|
|
|
s() { return zigzagDecode(this.u()); }
|
|
|
|
|
b() { this.ensure(1); return this.bytes[this.pos++]; }
|
|
|
|
|
str() {
|
|
|
|
|
const len = this.u();
|
|
|
|
|
this.ensure(len);
|
|
|
|
|
const slice = this.bytes.subarray(this.pos, this.pos + len);
|
|
|
|
|
this.pos += len;
|
|
|
|
|
return textDecoder.decode(slice);
|
2026-06-25 14:51:58 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 17:43:24 +09:00
|
|
|
function makeProfile(snapshot = {}) {
|
|
|
|
|
const tarinaiRows = Array.isArray(snapshot.t) ? snapshot.t : [];
|
|
|
|
|
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
|
|
|
|
|
return {
|
|
|
|
|
format: "TJ1",
|
|
|
|
|
schema: BINARY_SCHEMA_VERSION,
|
|
|
|
|
alphabetSize: SAVE_TEXT_BASE,
|
|
|
|
|
counts: { tarinai: tarinaiRows.length, items: itemRows.length },
|
|
|
|
|
sections: {},
|
|
|
|
|
itemTypes: {},
|
|
|
|
|
compression: {},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addProfileBytes(profile, key, bytes = 0) {
|
|
|
|
|
if (!profile || !key) return;
|
|
|
|
|
const n = Math.max(0, Number(bytes) || 0);
|
|
|
|
|
profile.sections[key] = (profile.sections[key] || 0) + n;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trackBytes(writer, profile, key, fn) {
|
|
|
|
|
if (!profile || !writer) return fn();
|
|
|
|
|
const start = writer.bytes.length;
|
|
|
|
|
const result = fn();
|
|
|
|
|
addProfileBytes(profile, key, writer.bytes.length - start);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addItemTypeProfile(profile, typeId, bytes = 0) {
|
|
|
|
|
if (!profile) return;
|
|
|
|
|
const type = ITEM_TYPE_IDS[typeId] || `type_${typeId}`;
|
|
|
|
|
const entry = profile.itemTypes[type] || { count: 0, bytes: 0 };
|
|
|
|
|
entry.count += 1;
|
|
|
|
|
entry.bytes += Math.max(0, Number(bytes) || 0);
|
|
|
|
|
profile.itemTypes[type] = entry;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function finalizeProfile(profile, rawBytes, packedBytes, text, codec = "R") {
|
|
|
|
|
if (!profile) return null;
|
|
|
|
|
const raw = rawBytes?.length || 0;
|
|
|
|
|
const packed = packedBytes?.length || 0;
|
|
|
|
|
const payloadChars = Math.max(0, String(text || "").length - EXPORT_PREFIX.length - 1);
|
|
|
|
|
profile.sections.totalRaw = raw;
|
|
|
|
|
profile.compression = {
|
|
|
|
|
codec,
|
|
|
|
|
rawBytes: raw,
|
|
|
|
|
packedBytes: packed,
|
|
|
|
|
packedDelta: raw - packed,
|
|
|
|
|
ratio: raw > 0 ? packed / raw : 0,
|
|
|
|
|
prefixChars: EXPORT_PREFIX.length + 1,
|
|
|
|
|
payloadChars,
|
|
|
|
|
totalChars: String(text || "").length,
|
|
|
|
|
alphabetSize: SAVE_TEXT_BASE,
|
|
|
|
|
containsBacktick: false,
|
|
|
|
|
japaneseText: true,
|
|
|
|
|
bitsPerChar: SAVE_TEXT_BITS,
|
|
|
|
|
asciiOnly: false,
|
|
|
|
|
};
|
|
|
|
|
return profile;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-25 14:51:58 +09:00
|
|
|
async function compressBytes(bytes) {
|
|
|
|
|
if (typeof CompressionStream === "function") {
|
|
|
|
|
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("deflate-raw"));
|
|
|
|
|
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
|
|
|
}
|
|
|
|
|
return bytes;
|
|
|
|
|
}
|
|
|
|
|
async function decompressBytes(bytes, codec = "R") {
|
2026-06-25 17:43:24 +09:00
|
|
|
if (codec === DEFLATE_CODEC) {
|
2026-06-25 14:51:58 +09:00
|
|
|
if (typeof DecompressionStream !== "function") throw new Error("deflate decoder is not available in this browser");
|
|
|
|
|
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
|
|
|
|
|
return new Uint8Array(await new Response(stream).arrayBuffer());
|
|
|
|
|
}
|
2026-06-25 17:43:24 +09:00
|
|
|
if (codec !== RAW_CODEC) throw new Error("unsupported Japanese save codec");
|
2026-06-25 14:51:58 +09:00
|
|
|
return bytes;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 17:43:24 +09:00
|
|
|
function writeFixed(writer, arr = [], count = 0, signed = true) {
|
|
|
|
|
for (let i = 0; i < count; i++) signed ? writer.s(arr[i] || 0) : writer.u(arr[i] || 0);
|
|
|
|
|
}
|
|
|
|
|
function readFixed(reader, count = 0, signed = true) {
|
|
|
|
|
const out = [];
|
|
|
|
|
for (let i = 0; i < count; i++) out.push(signed ? reader.s() : reader.u());
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
function writeIndexList(writer, arr = []) {
|
|
|
|
|
const list = Array.isArray(arr) ? arr : [];
|
|
|
|
|
writer.u(list.length);
|
|
|
|
|
for (const idx of list) writer.u(idx || 0);
|
|
|
|
|
}
|
|
|
|
|
function readIndexList(reader) {
|
|
|
|
|
const count = reader.u();
|
|
|
|
|
const out = [];
|
|
|
|
|
for (let i = 0; i < count; i++) out.push(reader.u());
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function generatedNameCode(value = "") {
|
|
|
|
|
const name = String(value || "");
|
|
|
|
|
if (!name.startsWith("は")) return -1;
|
|
|
|
|
const stop = name.endsWith("っ");
|
|
|
|
|
const core = name.slice(1, stop ? -1 : undefined);
|
|
|
|
|
if (core.length > 6) return -1;
|
|
|
|
|
let bits = 0;
|
|
|
|
|
for (let i = 0; i < core.length; i++) {
|
|
|
|
|
const ch = core.charAt(i);
|
|
|
|
|
if (ch !== "う" && ch !== "ぅ") return -1;
|
|
|
|
|
if (ch === "ぅ") bits |= (1 << i);
|
|
|
|
|
}
|
|
|
|
|
return core.length | (stop ? 8 : 0) | (bits << 4);
|
|
|
|
|
}
|
|
|
|
|
function nameFromGeneratedCode(code = 0) {
|
|
|
|
|
const middleLen = code & 7;
|
|
|
|
|
const stop = !!(code & 8);
|
|
|
|
|
const bits = code >> 4;
|
|
|
|
|
let name = "は";
|
|
|
|
|
for (let i = 0; i < middleLen; i++) name += (bits & (1 << i)) ? "ぅ" : "う";
|
|
|
|
|
if (stop) name += "っ";
|
|
|
|
|
return name;
|
|
|
|
|
}
|
|
|
|
|
function writeName(writer, value = "") {
|
|
|
|
|
const code = generatedNameCode(value);
|
|
|
|
|
if (code >= 0) {
|
|
|
|
|
writer.u(code + 1);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
writer.u(0);
|
|
|
|
|
writer.str(value || "");
|
|
|
|
|
}
|
|
|
|
|
function readName(reader) {
|
|
|
|
|
const code = reader.u();
|
|
|
|
|
if (code > 0) return nameFromGeneratedCode(code - 1);
|
|
|
|
|
return reader.str();
|
|
|
|
|
}
|
|
|
|
|
function valuesDiffer(a, b) { return (Number(a) || 0) !== (Number(b) || 0); }
|
|
|
|
|
function writeFixedDiff(writer, base = [], current = [], count = 0, signed = false) {
|
|
|
|
|
let mask = 0;
|
|
|
|
|
for (let i = 0; i < count; i++) if (valuesDiffer(current?.[i], base?.[i])) mask |= (1 << i);
|
|
|
|
|
writer.u(mask);
|
|
|
|
|
for (let i = 0; i < count; i++) if (mask & (1 << i)) signed ? writer.s(current[i] || 0) : writer.u(current[i] || 0);
|
|
|
|
|
}
|
|
|
|
|
function readFixedDiff(reader, base = [], count = 0, signed = false) {
|
|
|
|
|
const out = Array.from({ length: count }, (_, i) => base?.[i] || 0);
|
|
|
|
|
const mask = reader.u();
|
|
|
|
|
for (let i = 0; i < count; i++) if (mask & (1 << i)) out[i] = signed ? reader.s() : reader.u();
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
function writeTimers(writer, timers = []) {
|
|
|
|
|
const defaults = [0, 0, 0, 0, 0, 0, 0, 0, 100];
|
|
|
|
|
let mask = 0;
|
|
|
|
|
for (let i = 0; i < defaults.length; i++) if (valuesDiffer(timers?.[i], defaults[i])) mask |= (1 << i);
|
|
|
|
|
writer.u(mask);
|
|
|
|
|
for (let i = 0; i < defaults.length; i++) if (mask & (1 << i)) writer.s(timers[i] || 0);
|
|
|
|
|
}
|
|
|
|
|
function readTimers(reader) {
|
|
|
|
|
const defaults = [0, 0, 0, 0, 0, 0, 0, 0, 100];
|
|
|
|
|
const out = defaults.slice();
|
|
|
|
|
const mask = reader.u();
|
|
|
|
|
for (let i = 0; i < defaults.length; i++) if (mask & (1 << i)) out[i] = reader.s();
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
function writeModes(writer, modes = []) {
|
|
|
|
|
let mask = 0;
|
|
|
|
|
for (let i = 0; i < 3; i++) if ((Number(modes?.[i]) || 0) !== 0) mask |= (1 << i);
|
|
|
|
|
writer.u(mask);
|
|
|
|
|
for (let i = 0; i < 3; i++) if (mask & (1 << i)) writer.u(modes[i] || 0);
|
|
|
|
|
}
|
|
|
|
|
function readModes(reader) {
|
|
|
|
|
const out = [0, 0, 0];
|
|
|
|
|
const mask = reader.u();
|
|
|
|
|
for (let i = 0; i < 3; i++) if (mask & (1 << i)) out[i] = reader.u();
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
function commonItemAmountNeeded(typeId) {
|
|
|
|
|
if (STRUCTURE_TYPE_IDS.has(typeId)) return false;
|
|
|
|
|
if (SERVING_FOOD_TYPE_IDS.has(typeId)) return false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
function writeRelationships(writer, rows = []) {
|
|
|
|
|
const list = Array.isArray(rows) ? rows : [];
|
|
|
|
|
writer.u(list.length);
|
|
|
|
|
for (const row of list) {
|
|
|
|
|
writer.u(row?.[0] || 0);
|
|
|
|
|
writer.s(row?.[1] || 0);
|
|
|
|
|
writer.s(row?.[2] || 0);
|
|
|
|
|
writer.u(row?.[3] || 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
function readRelationships(reader) {
|
|
|
|
|
const count = reader.u();
|
|
|
|
|
const out = [];
|
|
|
|
|
for (let i = 0; i < count; i++) out.push([reader.u(), reader.s(), reader.s(), reader.u()]);
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function writeWorld(writer, w = []) {
|
|
|
|
|
writer.u(w[0] || 0); // field
|
|
|
|
|
writer.u(w[1] || 0); // ground
|
|
|
|
|
writer.u(w[2] || 0); // mood
|
|
|
|
|
writer.u(w[3] || 0); // worldTick10
|
|
|
|
|
writer.u(w[4] || 0); // weather
|
|
|
|
|
writer.s(w[5] || 0); // lastBirthAt10 can be negative
|
|
|
|
|
writer.u(w[6] || 1); // generation
|
|
|
|
|
writer.u(w[7] || 0); // dead count
|
|
|
|
|
}
|
|
|
|
|
function readWorld(reader) {
|
|
|
|
|
return [reader.u(), reader.u(), reader.u(), reader.u(), reader.u(), reader.s(), reader.u(), reader.u()];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function writeTarinai(writer, row = [], profile = null) {
|
|
|
|
|
const baseSprite = row[2] || 0;
|
|
|
|
|
const goodMode = row[24] || 0;
|
|
|
|
|
const pinIdx = row[21] ?? -1;
|
|
|
|
|
const nestIdx = row[22] ?? -1;
|
|
|
|
|
const stateId = row[23]?.[0] || 0;
|
|
|
|
|
const stateTarget = row[23]?.[1] ?? -1;
|
|
|
|
|
trackBytes(writer, profile, "tarinai.flags", () => writer.u(row[0] || 0));
|
|
|
|
|
trackBytes(writer, profile, "tarinai.name", () => writeName(writer, row[1] || ""));
|
|
|
|
|
trackBytes(writer, profile, "tarinai.core", () => {
|
|
|
|
|
writer.u(baseSprite);
|
|
|
|
|
writer.s(row[3] || 0);
|
|
|
|
|
writer.s(row[4] || 0);
|
|
|
|
|
writer.u(row[5] || 0);
|
|
|
|
|
writer.u(row[6] || 0);
|
|
|
|
|
writer.u(row[7] || 0);
|
|
|
|
|
writer.u(row[8] || 1);
|
|
|
|
|
writer.u(row[9] || 0);
|
|
|
|
|
writer.u(row[10] || 0);
|
|
|
|
|
writer.u(row[11] || 0);
|
|
|
|
|
});
|
|
|
|
|
trackBytes(writer, profile, "tarinai.needs", () => writeFixed(writer, row[12], 6, false));
|
|
|
|
|
trackBytes(writer, profile, "tarinai.traits", () => {
|
|
|
|
|
writeFixed(writer, row[13], 4, false);
|
|
|
|
|
writeFixedDiff(writer, row[13], row[14], 4, false);
|
|
|
|
|
writeFixed(writer, row[15], 4, false);
|
|
|
|
|
});
|
|
|
|
|
trackBytes(writer, profile, "tarinai.family", () => {
|
|
|
|
|
writeIndexList(writer, row[16]);
|
|
|
|
|
writeIndexList(writer, row[17]);
|
|
|
|
|
});
|
|
|
|
|
trackBytes(writer, profile, "tarinai.relationships", () => writeRelationships(writer, row[18]));
|
|
|
|
|
trackBytes(writer, profile, "tarinai.timers", () => writeTimers(writer, row[19]));
|
|
|
|
|
trackBytes(writer, profile, "tarinai.modes", () => writeModes(writer, row[20]));
|
|
|
|
|
trackBytes(writer, profile, "tarinai.tail", () => {
|
|
|
|
|
let tailMask = 0;
|
|
|
|
|
if (pinIdx >= 0) tailMask |= 1;
|
|
|
|
|
if (nestIdx >= 0) tailMask |= 2;
|
|
|
|
|
if (stateId || stateTarget >= 0) tailMask |= 4;
|
|
|
|
|
if (goodMode) tailMask |= 8;
|
|
|
|
|
writer.u(tailMask);
|
|
|
|
|
if (tailMask & 1) writer.u(pinIdx);
|
|
|
|
|
if (tailMask & 2) writer.u(nestIdx);
|
|
|
|
|
if (tailMask & 4) { writer.u(stateId); writer.s(stateTarget); }
|
|
|
|
|
if (tailMask & 8) writer.u(goodMode);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
function readTarinai(reader) {
|
|
|
|
|
const flags = reader.u();
|
|
|
|
|
const name = readName(reader);
|
|
|
|
|
const baseSprite = reader.u();
|
|
|
|
|
const x = reader.s();
|
|
|
|
|
const y = reader.s();
|
|
|
|
|
const adultScale = reader.u();
|
|
|
|
|
const age = reader.u();
|
|
|
|
|
const lifeSpan = reader.u();
|
|
|
|
|
const generation = reader.u();
|
|
|
|
|
const hunger = reader.u();
|
|
|
|
|
const energy = reader.u();
|
|
|
|
|
const sleepPressure = reader.u();
|
|
|
|
|
const needs = readFixed(reader, 6, false);
|
|
|
|
|
const birth = readFixed(reader, 4, false);
|
|
|
|
|
const current = readFixedDiff(reader, birth, 4, false);
|
|
|
|
|
const genetics = readFixed(reader, 4, false);
|
|
|
|
|
const parents = readIndexList(reader);
|
|
|
|
|
const children = readIndexList(reader);
|
|
|
|
|
const relationships = readRelationships(reader);
|
|
|
|
|
const timers = readTimers(reader);
|
|
|
|
|
const modes = readModes(reader);
|
|
|
|
|
const tailMask = reader.u();
|
|
|
|
|
const pinIdx = (tailMask & 1) ? reader.u() : -1;
|
|
|
|
|
const nestIdx = (tailMask & 2) ? reader.u() : -1;
|
|
|
|
|
const stateInfo = (tailMask & 4) ? [reader.u(), reader.s()] : [0, -1];
|
|
|
|
|
const goodMode = (tailMask & 8) ? reader.u() : 0;
|
|
|
|
|
return [
|
|
|
|
|
flags, name, baseSprite, x, y, adultScale, age, lifeSpan, generation, hunger, energy, sleepPressure,
|
|
|
|
|
needs, birth, current, genetics, parents, children, relationships, timers, modes, pinIdx, nestIdx, stateInfo, goodMode
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function writeItemExtra(writer, typeId, extra = []) {
|
|
|
|
|
if (STRUCTURE_TYPE_IDS.has(typeId)) {
|
|
|
|
|
writer.s(extra[0] || 0); writer.s(extra[1] || 0); writer.s(extra[2] ?? -1); writer.s(extra[3] ?? -1); writer.u(extra[4] || 0); writer.s(extra[5] ?? -1);
|
|
|
|
|
} else if (typeId === 0) { // grass
|
|
|
|
|
writer.s(extra[0] || 0); writer.s(extra[1] || 0); writer.s(extra[2] || 0); writer.u(extra[3] || 0);
|
|
|
|
|
} else if (typeId === 1) { // zunchi
|
|
|
|
|
writer.u(extra[0] || 0); writer.s(extra[1] || 0); writer.s(extra[2] || 0);
|
|
|
|
|
} else if (typeId === 22) { // signboard
|
|
|
|
|
writer.str(extra[0] || "");
|
|
|
|
|
} else if (typeId === 6) { // duplicator
|
|
|
|
|
writer.s(extra[0] ?? -1);
|
|
|
|
|
} else if (typeId === 21) { // ball
|
|
|
|
|
writer.s(extra[0] || 0); writer.s(extra[1] || 0);
|
|
|
|
|
} else if (typeId === 23) { // ant_nest
|
|
|
|
|
writer.s(extra[0] || 0); writer.s(extra[1] || 0);
|
|
|
|
|
} else if (typeId === 24) { // ant_corpse
|
|
|
|
|
writer.s(extra[0] || 0);
|
|
|
|
|
} else if (typeId === 25) { // firecracker
|
|
|
|
|
writer.s(extra[0] || 0);
|
|
|
|
|
} else if (PIN_TYPE_IDS.has(typeId)) {
|
|
|
|
|
writer.u(extra[0] || 0); writer.s(extra[1] ?? -1); writer.s(extra[2] || 0); writer.s(extra[3] || 0); writer.s(extra[4] || 0);
|
|
|
|
|
} else if (SERVING_FOOD_TYPE_IDS.has(typeId)) {
|
|
|
|
|
writer.s(extra[0] || 0); writer.s(extra[1] || 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
function readItemExtra(reader, typeId) {
|
|
|
|
|
if (STRUCTURE_TYPE_IDS.has(typeId)) return [reader.s(), reader.s(), reader.s(), reader.s(), reader.u(), reader.s()];
|
|
|
|
|
if (typeId === 0) return [reader.s(), reader.s(), reader.s(), reader.u()];
|
|
|
|
|
if (typeId === 1) return [reader.u(), reader.s(), reader.s()];
|
|
|
|
|
if (typeId === 22) return [reader.str()];
|
|
|
|
|
if (typeId === 6) return [reader.s()];
|
|
|
|
|
if (typeId === 21) return [reader.s(), reader.s()];
|
|
|
|
|
if (typeId === 23) return [reader.s(), reader.s()];
|
|
|
|
|
if (typeId === 24) return [reader.s()];
|
|
|
|
|
if (typeId === 25) return [reader.s()];
|
|
|
|
|
if (PIN_TYPE_IDS.has(typeId)) return [reader.u(), reader.s(), reader.s(), reader.s(), reader.s()];
|
|
|
|
|
if (SERVING_FOOD_TYPE_IDS.has(typeId)) return [reader.s(), reader.s()];
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
function writeItem(writer, row = [], profile = null) {
|
|
|
|
|
const typeId = row[0] || 0;
|
|
|
|
|
const start = writer.bytes.length;
|
|
|
|
|
trackBytes(writer, profile, "items.core", () => {
|
|
|
|
|
writer.u(typeId);
|
|
|
|
|
writer.s(row[1] || 0);
|
|
|
|
|
writer.s(row[2] || 0);
|
|
|
|
|
if (commonItemAmountNeeded(typeId)) writer.s(row[3] || 0);
|
|
|
|
|
});
|
|
|
|
|
trackBytes(writer, profile, "items.extra", () => writeItemExtra(writer, typeId, row[4] || []));
|
|
|
|
|
addItemTypeProfile(profile, typeId, writer.bytes.length - start);
|
|
|
|
|
}
|
|
|
|
|
function readItem(reader) {
|
|
|
|
|
const typeId = reader.u();
|
|
|
|
|
const x = reader.s();
|
|
|
|
|
const y = reader.s();
|
|
|
|
|
const amount = commonItemAmountNeeded(typeId) ? reader.s() : 0;
|
|
|
|
|
const extra = readItemExtra(reader, typeId);
|
|
|
|
|
let restoredAmount = amount;
|
|
|
|
|
if (STRUCTURE_TYPE_IDS.has(typeId)) restoredAmount = extra[0] || 0;
|
|
|
|
|
else if (SERVING_FOOD_TYPE_IDS.has(typeId)) restoredAmount = extra[0] || 0;
|
|
|
|
|
return [typeId, x, y, restoredAmount, extra];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function fieldValue(index = 0) { return FIELD_IDS[Number(index) | 0] || "garden"; }
|
|
|
|
|
function groundValue(index = 0) { return GROUND_IDS[Number(index) | 0] || "soil"; }
|
|
|
|
|
function summaryFromPayload(w = [], tarinaiCount = 0, itemCount = 0) {
|
|
|
|
|
const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
|
|
|
|
|
const dayLength = Math.max(1, Number(config.dayLength || 120));
|
|
|
|
|
const totalTime = (Number(w[3]) || 0) / 10;
|
|
|
|
|
const day = Math.max(1, Math.floor(totalTime / dayLength) + 1);
|
|
|
|
|
const time10 = Math.round((totalTime - (day - 1) * dayLength) * 10);
|
|
|
|
|
return [day, tarinaiCount, fieldValue(w[0]), time10, itemCount, groundValue(w[1])];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function encodeBinarySnapshot(snapshot, options = {}) {
|
|
|
|
|
if (!snapshot || snapshot.v !== Snapshot.version || snapshot.a !== "tj1") throw new Error("snapshot version mismatch");
|
|
|
|
|
const profile = options?.profile ? makeProfile(snapshot) : null;
|
|
|
|
|
const writer = new BinaryWriter();
|
|
|
|
|
trackBytes(writer, profile, "header", () => {
|
|
|
|
|
writer.b(BINARY_SCHEMA_VERSION);
|
|
|
|
|
writer.u(Snapshot.version);
|
|
|
|
|
});
|
|
|
|
|
trackBytes(writer, profile, "world", () => writeWorld(writer, snapshot.w || []));
|
|
|
|
|
const tarinaiRows = Array.isArray(snapshot.t) ? snapshot.t : [];
|
|
|
|
|
trackBytes(writer, profile, "tarinai.count", () => writer.u(tarinaiRows.length));
|
|
|
|
|
const tarinaiStart = writer.bytes.length;
|
|
|
|
|
for (const row of tarinaiRows) writeTarinai(writer, row || [], profile);
|
|
|
|
|
addProfileBytes(profile, "tarinai.total", writer.bytes.length - tarinaiStart);
|
|
|
|
|
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
|
|
|
|
|
trackBytes(writer, profile, "items.count", () => writer.u(itemRows.length));
|
|
|
|
|
const itemStart = writer.bytes.length;
|
|
|
|
|
for (const row of itemRows) writeItem(writer, row || [], profile);
|
|
|
|
|
addProfileBytes(profile, "items.total", writer.bytes.length - itemStart);
|
|
|
|
|
const bytes = writer.out();
|
|
|
|
|
if (profile) return { bytes, profile };
|
|
|
|
|
return bytes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function decodeBinarySnapshot(bytes) {
|
|
|
|
|
const reader = new BinaryReader(bytes);
|
|
|
|
|
const schema = reader.b();
|
|
|
|
|
if (schema !== BINARY_SCHEMA_VERSION) throw new Error("unsupported binary save schema");
|
|
|
|
|
const snapshotVersion = reader.u();
|
|
|
|
|
if (snapshotVersion !== Snapshot.version) throw new Error("unsupported binary save version");
|
|
|
|
|
const w = readWorld(reader);
|
|
|
|
|
const tCount = reader.u();
|
|
|
|
|
const t = [];
|
|
|
|
|
for (let i = 0; i < tCount; i++) t.push(readTarinai(reader));
|
|
|
|
|
const itemCount = reader.u();
|
|
|
|
|
const items = [];
|
|
|
|
|
for (let i = 0; i < itemCount; i++) items.push(readItem(reader));
|
|
|
|
|
if (reader.pos !== reader.bytes.length) throw new Error("trailing bytes in binary save");
|
|
|
|
|
return { v: Snapshot.version, a: "tj1", c: Date.now(), m: summaryFromPayload(w, t.length, items.length), w, t, i: items };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function packBytesForSave(bytes) {
|
|
|
|
|
let codec = RAW_CODEC;
|
2026-06-25 14:51:58 +09:00
|
|
|
let packed = bytes;
|
|
|
|
|
try {
|
|
|
|
|
const compressed = await compressBytes(bytes);
|
2026-06-25 17:43:24 +09:00
|
|
|
if (compressed.length < bytes.length) { codec = DEFLATE_CODEC; packed = compressed; }
|
|
|
|
|
} catch (error) { console.warn("save compression fallback", error); }
|
|
|
|
|
return { codec, packed };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function encodeSnapshotWithProfile(snapshot) {
|
|
|
|
|
const encoded = encodeBinarySnapshot(snapshot, { profile: true });
|
|
|
|
|
const bytes = encoded.bytes || encoded;
|
|
|
|
|
const profile = encoded.profile || null;
|
|
|
|
|
const { codec, packed } = await packBytesForSave(bytes);
|
|
|
|
|
const text = `${EXPORT_PREFIX}${codec}${jp2048Encode(packed)}`;
|
|
|
|
|
finalizeProfile(profile, bytes, packed, text, codec);
|
|
|
|
|
return { text, profile };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function encodeSnapshot(snapshot) {
|
|
|
|
|
return (await encodeSnapshotWithProfile(snapshot)).text;
|
2026-06-25 14:51:58 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function decodeSnapshot(text) {
|
|
|
|
|
const raw = String(text || "").trim();
|
2026-06-25 17:43:24 +09:00
|
|
|
if (!raw.startsWith(EXPORT_PREFIX)) throw new Error("unsupported Japanese save text");
|
|
|
|
|
const codec = raw.charAt(EXPORT_PREFIX.length) || RAW_CODEC;
|
2026-06-25 14:51:58 +09:00
|
|
|
const payload = raw.slice(EXPORT_PREFIX.length + 1);
|
2026-06-25 17:43:24 +09:00
|
|
|
const packed = jp2048Decode(payload);
|
2026-06-25 14:51:58 +09:00
|
|
|
const bytes = await decompressBytes(packed, codec);
|
2026-06-25 17:43:24 +09:00
|
|
|
return decodeBinarySnapshot(bytes);
|
2026-06-25 14:51:58 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
global.TarinaiSaveCodec = {
|
|
|
|
|
EXPORT_PREFIX,
|
2026-06-25 17:43:24 +09:00
|
|
|
RAW_CODEC,
|
|
|
|
|
DEFLATE_CODEC,
|
2026-06-25 14:51:58 +09:00
|
|
|
SAVE_TEXT_ALPHABET,
|
2026-06-25 17:43:24 +09:00
|
|
|
SAVE_TEXT_BASE,
|
|
|
|
|
SAVE_TEXT_BITS,
|
|
|
|
|
jp2048Encode,
|
|
|
|
|
jp2048Decode,
|
2026-06-25 14:51:58 +09:00
|
|
|
encodeSnapshot,
|
2026-06-25 17:43:24 +09:00
|
|
|
encodeSnapshotWithProfile,
|
2026-06-25 14:51:58 +09:00
|
|
|
decodeSnapshot,
|
2026-06-25 17:43:24 +09:00
|
|
|
encodeBinarySnapshot,
|
|
|
|
|
decodeBinarySnapshot,
|
|
|
|
|
BinaryWriter,
|
|
|
|
|
BinaryReader,
|
2026-06-25 14:51:58 +09:00
|
|
|
};
|
|
|
|
|
})(typeof window !== "undefined" ? window : globalThis);
|