tarinai/js/save_codec.js
2026-07-29 20:52:11 +09:00

1888 lines
84 KiB
JavaScript

"use strict";
(function (global) {
const Snapshot = global.TarinaiSnapshot;
const SaveSchema = global.TarinaiSaveSchema;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_codec.js");
if (!SaveSchema) throw new Error("TarinaiSaveSchema is not available for save_codec.js");
const EXPORT_PREFIX = "\u305f";
const {
BINARY_SCHEMA_VERSION,
FIELD_IDS,
} = SaveSchema;
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const SAVE_STATS_SECTION_ORDER = Object.freeze([
"format",
"stringTable",
"world",
"tarinaiBasic",
"tarinaiSeed",
"tarinaiRelationships",
"itemIndex",
"normalItems",
"physicsItems",
"achievementMetadata",
"extendedState",
]);
let lastEncodeStats = null;
let lastDecodeStats = null;
function nowMs() {
return Number(global.performance?.now?.() ?? Date.now());
}
function debugSaveStatsEnabled() {
if (global.__tarinaiDebugOverlayEnabled === true) return true;
try {
const params = new URLSearchParams(global.location?.search || "");
return params.get("debug") === "1" || params.get("debug") === "true";
} catch (_) {
return false;
}
}
function createSaveStats(snapshot) {
return {
createdAt: Date.now(),
sections: Object.fromEntries(SAVE_STATS_SECTION_ORDER.map(key => [key, 0])),
details: {
tarinaiCount: Array.isArray(snapshot?.t) ? snapshot.t.length : 0,
itemCount: Array.isArray(snapshot?.i) ? snapshot.i.length : 0,
normalItemCount: 0,
physicsItemCount: 0,
stringTableEntries: 0,
relationshipRows: 0,
parentLinks: 0,
birthSeedRawUtf8Bytes: 0,
normalItemExtraEncodedBytes: 0,
physicsItemExtraEncodedBytes: 0,
normalItemExtraRawUtf8Bytes: 0,
physicsItemExtraRawUtf8Bytes: 0,
},
binaryBytes: 0,
rawBinaryBytes: 0,
compressedBytes: 0,
compressionSavedBytes: 0,
compressed: false,
textBits: 15,
hashChars: 0,
payloadChars: 0,
encodeMs: 0,
accountedBytes: 0,
unaccountedBytes: 0,
};
}
const SAVE_TEXT_BITS = 15;
const SAVE_TEXT_BASE = 1 << SAVE_TEXT_BITS;
const SAVE_TEXT_RANGE_A_START = 0x3400;
const SAVE_TEXT_RANGE_A_COUNT = 0x4DC0 - 0x3400; // 6592
const SAVE_TEXT_RANGE_B_START = 0x4E00;
const SAVE_TEXT_RANGE_B_COUNT = 0xA000 - 0x4E00; // 20992
const SAVE_TEXT_RANGE_C_START = 0xAC00;
const SAVE_TEXT_RANGE_C_COUNT = SAVE_TEXT_BASE - SAVE_TEXT_RANGE_A_COUNT - SAVE_TEXT_RANGE_B_COUNT; // 5184
function saveTextCharFromValue(value = 0) {
const n = Number(value) & (SAVE_TEXT_BASE - 1);
if (n < SAVE_TEXT_RANGE_A_COUNT) return String.fromCharCode(SAVE_TEXT_RANGE_A_START + n);
const afterA = n - SAVE_TEXT_RANGE_A_COUNT;
if (afterA < SAVE_TEXT_RANGE_B_COUNT) return String.fromCharCode(SAVE_TEXT_RANGE_B_START + afterA);
return String.fromCharCode(SAVE_TEXT_RANGE_C_START + afterA - SAVE_TEXT_RANGE_B_COUNT);
}
function saveTextValueFromCode(code = 0) {
const n = Number(code) || 0;
if (n >= SAVE_TEXT_RANGE_A_START && n < SAVE_TEXT_RANGE_A_START + SAVE_TEXT_RANGE_A_COUNT) return n - SAVE_TEXT_RANGE_A_START;
if (n >= SAVE_TEXT_RANGE_B_START && n < SAVE_TEXT_RANGE_B_START + SAVE_TEXT_RANGE_B_COUNT) return SAVE_TEXT_RANGE_A_COUNT + n - SAVE_TEXT_RANGE_B_START;
if (n >= SAVE_TEXT_RANGE_C_START && n < SAVE_TEXT_RANGE_C_START + SAVE_TEXT_RANGE_C_COUNT) return SAVE_TEXT_RANGE_A_COUNT + SAVE_TEXT_RANGE_B_COUNT + n - SAVE_TEXT_RANGE_C_START;
return -1;
}
function base32768Encode(bytes) {
const source = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []);
let buffer = 0;
let bits = 0;
let out = "";
for (const byte of source) {
buffer |= (byte & 255) << bits;
bits += 8;
while (bits >= SAVE_TEXT_BITS) {
out += saveTextCharFromValue(buffer);
buffer = Math.floor(buffer / SAVE_TEXT_BASE);
bits -= SAVE_TEXT_BITS;
}
}
if (bits > 0) out += saveTextCharFromValue(buffer);
return out;
}
function base32768Decode(text) {
let buffer = 0;
let bits = 0;
const out = [];
const source = String(text || "").trim();
for (let i = 0; i < source.length; i++) {
const value = saveTextValueFromCode(source.charCodeAt(i));
if (value < 0) continue;
buffer += value * (2 ** bits);
bits += SAVE_TEXT_BITS;
while (bits >= 8) {
out.push(buffer & 255);
buffer = Math.floor(buffer / 256);
bits -= 8;
}
}
return new Uint8Array(out);
}
function encodeVarUint(value = 0) {
let n = toSafeUInt(value);
const out = [];
while (n >= 128) { out.push((n % 128) | 128); n = Math.floor(n / 128); }
out.push(n & 127);
return new Uint8Array(out);
}
function decodeVarUintPrefix(bytes) {
let n = 0;
let mul = 1;
for (let i = 0; i < Math.min(10, bytes.length); i++) {
const byte = bytes[i];
n += (byte & 127) * mul;
if (!(byte & 128)) return { value: n, length: i + 1 };
mul *= 128;
}
throw new Error("invalid save frame length");
}
function concatBytes(...parts) {
const arrays = parts.map(part => part instanceof Uint8Array ? part : new Uint8Array(part || []));
const total = arrays.reduce((sum, part) => sum + part.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of arrays) { out.set(part, offset); offset += part.length; }
return out;
}
async function transformBytes(bytes, format, decompress = false) {
const Ctor = decompress ? global.DecompressionStream : global.CompressionStream;
if (typeof Ctor !== "function") return null;
try {
const stream = new Ctor(format);
const reader = stream.readable.getReader();
const sink = stream.writable.getWriter();
const chunks = [];
let total = 0;
// Read the output concurrently with writes. Waiting for write/close before
// draining the readable side can deadlock in browsers that apply stream
// backpressure to CompressionStream/DecompressionStream.
const readTask = (async () => {
while (true) {
const { value, done } = await reader.read();
if (done) break;
const chunk = value instanceof Uint8Array ? value : new Uint8Array(value || []);
chunks.push(chunk);
total += chunk.length;
}
})();
try {
await sink.write(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || []));
await sink.close();
await readTask;
} catch (error) {
try { await sink.abort(error); } catch (_) {}
try { await reader.cancel(error); } catch (_) {}
try { await readTask; } catch (_) {}
throw error;
} finally {
try { sink.releaseLock(); } catch (_) {}
try { reader.releaseLock(); } catch (_) {}
}
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) { out.set(chunk, offset); offset += chunk.length; }
return out;
} catch (_) {
return null;
}
}
async function makeSaveFrame(rawBytes) {
const raw = rawBytes instanceof Uint8Array ? rawBytes : new Uint8Array(rawBytes || []);
const compressed = await transformBytes(raw, "deflate", false);
const useCompressed = Boolean(compressed && compressed.length < raw.length);
const payload = useCompressed ? compressed : raw;
const taggedLength = payload.length * 2 + (useCompressed ? 1 : 0);
return {
frame: concatBytes(encodeVarUint(taggedLength), payload),
compressed: useCompressed,
compressedBytes: payload.length,
};
}
async function openSaveFrame(encodedBytes) {
const all = encodedBytes instanceof Uint8Array ? encodedBytes : new Uint8Array(encodedBytes || []);
const prefix = decodeVarUintPrefix(all);
const compressed = Boolean(prefix.value & 1);
const payloadLength = Math.floor(prefix.value / 2);
const end = prefix.length + payloadLength;
if (payloadLength < 1 || end > all.length) throw new Error("truncated save frame");
const payload = all.subarray(prefix.length, end);
if (!compressed) return { raw: payload, frameBytes: end, compressed: false };
const raw = await transformBytes(payload, "deflate", true);
if (!raw) throw new Error("deflate save data is not supported in this browser");
return { raw, frameBytes: end, compressed: true };
}
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(stats = null) {
this.bytes = [];
this.stats = stats;
this.activeCategory = "format";
}
get length() { return this.bytes.length; }
pushByte(value = 0) {
this.bytes.push((Number(value) || 0) & 255);
if (this.stats) {
const key = this.activeCategory || "format";
this.stats.sections[key] = (this.stats.sections[key] || 0) + 1;
}
}
category(name, fn) {
const previous = this.activeCategory;
this.activeCategory = String(name || "format");
try {
return fn();
} finally {
this.activeCategory = previous;
}
}
setDetail(name, value) {
if (this.stats) this.stats.details[String(name)] = value;
}
addDetail(name, value = 1) {
if (!this.stats) return;
const key = String(name);
this.stats.details[key] = (Number(this.stats.details[key]) || 0) + (Number(value) || 0);
}
u(value = 0) {
let n = toSafeUInt(value);
while (n >= 128) {
this.pushByte((n % 128) | 128);
n = Math.floor(n / 128);
}
this.pushByte(n & 127);
}
s(value = 0) { this.u(zigzagEncode(value)); }
b(value = 0) { this.pushByte(value); }
u32(value = 0) {
const n = Number(value) >>> 0;
this.pushByte(n & 255);
this.pushByte((n >>> 8) & 255);
this.pushByte((n >>> 16) & 255);
this.pushByte((n >>> 24) & 255);
}
str(value = "") {
const bytes = textEncoder.encode(String(value || ""));
this.u(bytes.length);
for (const byte of bytes) this.pushByte(byte);
}
raw(value = []) {
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value || []);
for (const byte of bytes) this.pushByte(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;
}
throw new Error("invalid varuint in binary save");
}
s() { return zigzagDecode(this.u()); }
b() { this.ensure(1); return this.bytes[this.pos++]; }
u32() {
this.ensure(4);
const n = (this.bytes[this.pos]) | (this.bytes[this.pos + 1] << 8) | (this.bytes[this.pos + 2] << 16) | (this.bytes[this.pos + 3] << 24);
this.pos += 4;
return n >>> 0;
}
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);
}
raw(length = 0) {
const len = Math.max(0, Number(length) || 0);
this.ensure(len);
const slice = this.bytes.subarray(this.pos, this.pos + len);
this.pos += len;
return slice;
}
}
let activeStringWriteMap = null;
let activeStringReadList = null;
function utf8Length(value = "") {
return textEncoder.encode(String(value || "")).length;
}
function collectStringCounts(snapshot) {
const counts = new Map();
const add = value => {
const str = String(value || "");
if (str.length < 2 || utf8Length(str) < 4) return;
counts.set(str, (counts.get(str) || 0) + 1);
};
for (const row of snapshot?.i || []) {
if (isPhysicsTypeId(row?.[0] || 0)) continue;
const extra = Array.isArray(row?.[4]) ? row[4] : [];
for (const value of extra) if (typeof value === "string") add(value);
}
return [...counts.entries()]
.filter(([, count]) => count > 1)
.sort((a, b) => (b[0].length * b[1]) - (a[0].length * a[1]))
.map(([value]) => value)
.slice(0, 63);
}
function writeStringTable(writer, list = []) {
const values = Array.isArray(list) ? list : [];
writer.setDetail("stringTableEntries", values.length);
if (!values.length) {
activeStringWriteMap = null;
return;
}
writer.u(values.length);
activeStringWriteMap = new Map(values.map((value, i) => [value, i]));
for (const value of values) writer.str(value);
}
function readStringTable(reader, present = false) {
if (!present) {
activeStringReadList = null;
return;
}
const count = reader.u();
const list = [];
for (let i = 0; i < count; i++) list.push(reader.str());
activeStringReadList = list.length ? list : null;
}
function writeSaveString(writer, value = "") {
const s = String(value || "");
if (!activeStringWriteMap) return writer.str(s);
const index = activeStringWriteMap.get(s);
if (index == null) {
writer.u(0);
writer.str(s);
} else {
writer.u(index + 1);
}
}
function readSaveString(reader) {
if (!activeStringReadList) return reader.str();
const index = reader.u();
if (!index) return reader.str();
return activeStringReadList[index - 1] || "";
}
function clampToBits(value = 0, bits = 8) {
const max = (2 ** Math.max(0, bits)) - 1;
return Math.max(0, Math.min(max, toSafeUInt(value)));
}
function writeBitFields(writer, values = [], bits = []) {
let acc = 0;
let accBits = 0;
for (let i = 0; i < bits.length; i++) {
const width = Math.max(0, Number(bits[i]) || 0);
if (!width) continue;
let value = clampToBits(values?.[i] || 0, width);
let left = width;
while (left > 0) {
const room = 8 - accBits;
const take = Math.min(room, left);
const mask = (1 << take) - 1;
acc |= (value & mask) << accBits;
accBits += take;
value = Math.floor(value / (2 ** take));
left -= take;
if (accBits === 8) {
writer.b(acc);
acc = 0;
accBits = 0;
}
}
}
if (accBits > 0) writer.b(acc);
}
function readBitFields(reader, bits = []) {
const out = [];
let acc = 0;
let accBits = 0;
for (const rawWidth of bits) {
const width = Math.max(0, Number(rawWidth) || 0);
let value = 0;
let shift = 0;
let left = width;
while (left > 0) {
if (accBits === 0) { acc = reader.b(); accBits = 8; }
const take = Math.min(accBits, left);
const mask = (1 << take) - 1;
value += (acc & mask) * (2 ** shift);
acc = Math.floor(acc / (2 ** take));
accBits -= take;
shift += take;
left -= take;
}
out.push(value);
}
return out;
}
function writeQuantizedBitFields(writer, values = [], count = 0, bits = 6, step = 1) {
const list = [];
for (let i = 0; i < count; i++) list.push(Math.round((Number(values?.[i]) || 0) / Math.max(1, step)));
writeBitFields(writer, list, Array.from({ length: count }, () => bits));
}
function readQuantizedBitFields(reader, count = 0, bits = 6, step = 1) {
return readBitFields(reader, Array.from({ length: count }, () => bits)).map(v => v * Math.max(1, step));
}
function writeFixedDiffPacked(writer, base = [], current = [], count = 0, bits = 8) {
let mask = 0;
const values = [];
const widths = [];
for (let i = 0; i < count; i++) {
if (valuesDiffer(current?.[i], base?.[i])) {
mask |= (1 << i);
values.push(current?.[i] || 0);
widths.push(bits);
}
}
writeBitFields(writer, [mask], [count]);
if (widths.length) writeBitFields(writer, values, widths);
}
function readFixedDiffPacked(reader, base = [], count = 0, bits = 8) {
const [mask] = readBitFields(reader, [count]);
const out = Array.from({ length: count }, (_, i) => base?.[i] || 0);
const changed = [];
for (let i = 0; i < count; i++) if (mask & (1 << i)) changed.push(i);
const values = changed.length ? readBitFields(reader, changed.map(() => bits)) : [];
changed.forEach((index, i) => { out[index] = values[i]; });
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 valuesDiffer(a, b) { return (Number(a) || 0) !== (Number(b) || 0); }
function arrayHasValues(arr = [], defaults = []) {
for (let i = 0; i < Math.max(arr?.length || 0, defaults.length); i++) {
if (valuesDiffer(arr?.[i], defaults?.[i] || 0)) return true;
}
return false;
}
function writeTimers(writer, timers = []) {
const defaults = [0, 0, 0, 0, 0, 0, 0, 0, 100, 0];
let mask = 0;
for (let i = 0; i < defaults.length; i++) if (valuesDiffer(timers?.[i], defaults[i])) mask |= (1 << i);
writeBitFields(writer, [mask], [defaults.length]);
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, 0];
const out = defaults.slice();
const [mask] = readBitFields(reader, [defaults.length]);
for (let i = 0; i < defaults.length; i++) if (mask & (1 << i)) out[i] = reader.s();
return out;
}
function writeModes(writer, modes = []) {
writeBitFields(writer, [modes?.[0] || 0, modes?.[1] || 0, modes?.[2] || 0], [2, 2, 1]);
}
function readModes(reader) {
return readBitFields(reader, [2, 2, 1]);
}
function defaultNeedsFromVitals(hunger = 0, energy = 0, sleepPressure = 0) {
const food = Math.max(0, Math.min(100, Math.round(Number(hunger) || 0)));
const sleep = Math.max(0, Math.min(100, Math.round(Math.max(Number(sleepPressure) || 0, 100 - (Number(energy) || 0)))));
return [food, sleep, 0, 0, 0, 0];
}
function writeRelationships(writer, rows = []) {
const list = (Array.isArray(rows) ? rows : [])
.map(row => [toSafeUInt(row?.[0] || 0), toSafeInt(row?.[1] || 0), toSafeInt(row?.[2] || 0), toSafeUInt(row?.[3] || 0)])
.sort((a, b) => a[0] - b[0]);
writer.u(list.length);
let previousTarget = 0;
for (const row of list) {
writer.u(row[0] - previousTarget);
previousTarget = row[0];
writer.s(row[1]);
writer.s(row[2]);
writer.u(row[3]);
}
}
function readRelationships(reader) {
const count = reader.u();
const out = [];
let previousTarget = 0;
for (let i = 0; i < count; i++) {
const target = previousTarget + reader.u();
previousTarget = target;
out.push([target, reader.s(), reader.s(), reader.u()]);
}
return out;
}
function worldSeedWords(value = "") {
const seed = String(value || "");
const match = /^w([0-9a-f]{8})([0-9a-f]{8})$/i.exec(seed);
if (match) return [parseInt(match[1], 16) >>> 0, parseInt(match[2], 16) >>> 0];
let high = 2166136261;
let low = 2246822519;
for (let i = 0; i < seed.length; i++) {
const code = seed.charCodeAt(i);
high ^= code;
high = Math.imul(high, 16777619);
low ^= code + i * 131;
low = Math.imul(low, 3266489917);
}
return [high >>> 0, low >>> 0];
}
function worldSeedFromWords(high = 0, low = 0) {
if (typeof global.TarinaiSeedFactory?.worldSeedFromWords === "function") return global.TarinaiSeedFactory.worldSeedFromWords(high, low);
return `w${(Number(high) >>> 0).toString(16).padStart(8, "0")}${(Number(low) >>> 0).toString(16).padStart(8, "0")}`;
}
function canonicalWorldSeed(value = "") {
const [high, low] = worldSeedWords(value);
return worldSeedFromWords(high, low);
}
function writeWorld(writer, w = []) {
writeBitFields(writer, [w[0] || 0, w[1] || 0, w[2] || 0, w[4] || 0], [2, 3, 4, 3]);
writer.u(w[3] || 0); // total elapsed worldTick10
writer.u(Math.max(0, Number(w[18] ?? 0) || 0)); // explicit elapsed completed days
let mask = 0;
if (valuesDiffer(w[5], -9990)) mask |= 1;
if (valuesDiffer(w[6], 1)) mask |= 2;
if (valuesDiffer(w[7], 0)) mask |= 4;
if (valuesDiffer(w[9], 0)) mask |= 8;
if (valuesDiffer(w[10], 0)) mask |= 16;
if (valuesDiffer(w[11], 0)) mask |= 32;
if (valuesDiffer(w[19], 0)) mask |= 64;
writeBitFields(writer, [mask], [7]);
if (mask & 1) writer.s(w[5] || 0); // lastBirthAt10 can be negative
if (mask & 2) writer.u(w[6] || 1); // generation
if (mask & 4) writer.u(w[7] || 0); // dead count
const [seedHigh, seedLow] = worldSeedWords(w[8] || "");
writer.u32(seedHigh);
writer.u32(seedLow);
if (mask & 8) writer.u(w[9] || 0); // birthSerial
if (mask & 16) writer.u(w[10] || 0); // tarinai population limit
if (mask & 32) writer.u(w[11] || 0); // object limit
if (mask & 64) writer.u(w[19] || 0); // manual Tarinai additions
}
function readWorld(reader) {
const [field, ground, mood, weather] = readBitFields(reader, [2, 3, 4, 3]);
const tick = reader.u();
const elapsedDays = reader.u();
const [mask] = readBitFields(reader, [7]);
const lastBirthAt = (mask & 1) ? reader.s() : -9990;
const generation = (mask & 2) ? reader.u() : 1;
const deadCount = (mask & 4) ? reader.u() : 0;
const seed = worldSeedFromWords(reader.u32(), reader.u32());
const birthSerial = (mask & 8) ? reader.u() : 0;
const tarinaiPopulationLimit = (mask & 16) ? reader.u() : 0;
const objectLimit = (mask & 32) ? reader.u() : 0;
const manualTarinaiAddedCount = (mask & 64) ? reader.u() : 0;
const row = [field, ground, mood, tick, weather, lastBirthAt, generation, deadCount, seed, birthSerial, tarinaiPopulationLimit, objectLimit];
row[18] = elapsedDays;
row[19] = manualTarinaiAddedCount;
row[20] = BINARY_SCHEMA_VERSION;
return row;
}
const birthHash32 = global.TarinaiCoreHelpers?.birthHash32 || (seed => {
const match = /^b[0-9a-z]+_([0-9a-z]+)$/i.exec(String(seed || ""));
if (match) {
const parsed = parseInt(match[1], 36);
if (Number.isFinite(parsed)) return parsed >>> 0;
}
let h = 2166136261;
const value = String(seed || "");
for (let i = 0; i < value.length; i++) {
h ^= value.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
});
function birthSeedFromDescriptor(descriptor = [], worldSeed = "", fallbackSerial = 1) {
const serial = Math.max(1, Math.floor(Number(descriptor?.[0]) || fallbackSerial || 1));
const expected = global.TarinaiSeedFactory?.childSeed?.(worldSeed, serial, []) || `b${serial.toString(36)}_0`;
const expectedHash = birthHash32(expected);
const hash = Number.isFinite(Number(descriptor?.[1])) ? (Number(descriptor[1]) >>> 0) : expectedHash;
return { serial, hash, expectedHash, seed: hash === expectedHash ? expected : `b${serial.toString(36)}_${hash.toString(36)}` };
}
function writeBirthDescriptor(writer, descriptor = [], worldSeed = "", fallbackSerial = 1) {
const identity = birthSeedFromDescriptor(descriptor, worldSeed, fallbackSerial);
const customHash = identity.hash !== identity.expectedHash;
writer.u(identity.serial * 2 + (customHash ? 1 : 0));
if (customHash) writer.u32(identity.hash);
return identity.seed;
}
function readBirthDescriptor(reader, worldSeed = "", fallbackSerial = 1) {
const token = reader.u();
const serial = Math.max(1, Math.floor(token / 2) || fallbackSerial || 1);
const expected = global.TarinaiSeedFactory?.childSeed?.(worldSeed, serial, []) || `b${serial.toString(36)}_0`;
const hash = (token & 1) ? reader.u32() : birthHash32(expected);
return { descriptor: [serial, hash], seed: hash === birthHash32(expected) ? expected : `b${serial.toString(36)}_${hash.toString(36)}` };
}
function seedPersonalityArray(seed = "", flags = 0) {
// \u305a\u3093\u3061\u3069\u308c\u3044\u306f\u8a95\u751f\u6642\u6027\u683c\u3082\u5168\u8ef8 -1 \u306a\u306e\u3067\u3001\u5dee\u5206\u57fa\u6e96\u30820\u56fa\u5b9a\u306b\u3059\u308b\u3002
if ((Number(flags) || 0) & (1 << 5)) return [0, 0, 0, 0];
return global.TarinaiSeedFactory.personalityArray(seed) || [100, 100, 100, 100];
}
function seedGeneticsArray(seed = "", flags = 0) {
const zunchiSlave = Boolean((Number(flags) || 0) & ((1 << 5) | (1 << 6)));
const genetics = global.TarinaiSeedFactory?.birthProfile?.(seed, { isZunchiSlave: zunchiSlave, zunchiSlaveLocked: zunchiSlave })?.genetics || {};
return [
Math.round((Number(genetics.lifeSpanMul) || 1) * 100),
Math.round((Number(genetics.attackMul) || 1) * 100),
Math.round((Number(genetics.speedMul) || 1) * 100),
Math.round((Number(genetics.sizeMul) || 1) * 100),
Math.round((Number(genetics.temperatureOffset) || 0) * 10),
];
}
class CompactBitWriter {
constructor() { this.bytes = []; this.buffer = 0; this.bits = 0; }
write(value = 0, width = 0) {
let n = clampToBits(value, width);
let left = Math.max(0, Number(width) || 0);
while (left > 0) {
const room = 8 - this.bits;
const take = Math.min(room, left);
const mask = (1 << take) - 1;
this.buffer |= (n & mask) << this.bits;
this.bits += take;
n = Math.floor(n / (2 ** take));
left -= take;
if (this.bits === 8) { this.bytes.push(this.buffer); this.buffer = 0; this.bits = 0; }
}
}
finish() {
if (this.bits > 0) { this.bytes.push(this.buffer); this.buffer = 0; this.bits = 0; }
return new Uint8Array(this.bytes);
}
}
class CompactBitReader {
constructor(bytes = []) { this.bytes = bytes; this.pos = 0; this.buffer = 0; this.bits = 0; }
read(width = 0) {
let value = 0;
let shift = 0;
let left = Math.max(0, Number(width) || 0);
while (left > 0) {
if (this.bits === 0) {
if (this.pos >= this.bytes.length) throw new Error("truncated tarinai bit plane");
this.buffer = this.bytes[this.pos++];
this.bits = 8;
}
const take = Math.min(this.bits, left);
const mask = (1 << take) - 1;
value += (this.buffer & mask) * (2 ** shift);
this.buffer = Math.floor(this.buffer / (2 ** take));
this.bits -= take;
shift += take;
left -= take;
}
return value;
}
}
function diffMask(base = [], current = [], count = 0) {
let mask = 0;
for (let i = 0; i < count; i++) if (valuesDiffer(current?.[i], base?.[i])) mask |= (1 << i);
return mask;
}
function analyzeTarinaiRow(row = [], worldSeed = "", rowIndex = 0) {
const flags = row[0] || 0;
const birth = birthSeedFromDescriptor(row[1], worldSeed, rowIndex + 1);
const defaultPersonality = seedPersonalityArray(birth.seed, flags);
const defaultTimers = [0, 0, 0, 0, 0, 0, 0, 0, 100, 0];
const genetics = Array.isArray(row[18]) ? row[18] : [];
const fightStats = Array.isArray(row[19]) ? row[19] : [];
let bodyMask = 0;
if (arrayHasValues(row[10], defaultPersonality)) bodyMask |= 1;
if ((row[11] || []).length) bodyMask |= 2;
if ((row[12] || []).length) bodyMask |= 4;
if (arrayHasValues(row[13], defaultTimers)) bodyMask |= 8;
if (arrayHasValues(row[14], [0, 0, 0])) bodyMask |= 16;
const pinIdx = row[15] ?? -1;
const nestIdx = row[16] ?? -1;
const stateId = Math.max(0, Math.min(15, Number(row[17]?.[0]) || 0));
const stateTarget = row[17]?.[1] ?? -1;
const geneticsBase = seedGeneticsArray(birth.seed, flags);
let linkMask = 0;
if (pinIdx >= 0) linkMask |= 1;
if (nestIdx >= 0) linkMask |= 2;
return {
flags, birth, bodyMask, linkMask, pinIdx, nestIdx, stateId, stateTarget, genetics, fightStats,
geneticsBase, geneticsMask: diffMask(geneticsBase, genetics, 5), fightMask: diffMask([0, 0, 0], fightStats, 3),
hunger: Math.max(0, Math.min(100, Math.round(Number(row[6]) || 0))),
energy: Math.max(0, Math.min(240, Math.round(Number(row[7]) || 0))),
sleepPressure: Math.max(0, Math.min(100, Math.round(Number(row[8]) || 0))),
};
}
const TARINAI_COLUMN_LAYOUT = Object.freeze([
["flags", 10],
["hunger", 7],
["energy", 8],
["sleepPressure", 7],
["bodyMask", 5],
["linkMask", 2],
["stateId", 4],
["geneticsMask", 5],
["fightMask", 3],
]);
function writeTarinaiBitPlane(writer, metas = []) {
let columnMask = 0;
for (let column = 0; column < TARINAI_COLUMN_LAYOUT.length; column++) {
const key = TARINAI_COLUMN_LAYOUT[column][0];
if (metas.some(meta => (Number(meta?.[key]) || 0) !== 0)) columnMask |= (1 << column);
}
writer.u(columnMask);
const bits = new CompactBitWriter();
for (let column = 0; column < TARINAI_COLUMN_LAYOUT.length; column++) {
if (!(columnMask & (1 << column))) continue;
const [key, width] = TARINAI_COLUMN_LAYOUT[column];
for (const meta of metas) bits.write(meta?.[key] || 0, width);
}
writer.raw(bits.finish());
writer.setDetail("tarinaiColumnMask", columnMask);
}
function readTarinaiBitPlane(reader, count = 0) {
const columnMask = reader.u();
let totalBits = 0;
for (let column = 0; column < TARINAI_COLUMN_LAYOUT.length; column++) {
if (columnMask & (1 << column)) totalBits += Math.max(0, count) * TARINAI_COLUMN_LAYOUT[column][1];
}
const bits = new CompactBitReader(reader.raw(Math.ceil(totalBits / 8)));
const metas = Array.from({ length: Math.max(0, count) }, () => ({}));
for (let column = 0; column < TARINAI_COLUMN_LAYOUT.length; column++) {
const [key, width] = TARINAI_COLUMN_LAYOUT[column];
if (!(columnMask & (1 << column))) {
for (const meta of metas) meta[key] = 0;
continue;
}
for (const meta of metas) meta[key] = bits.read(width);
}
return metas;
}
function writeDiffValues(writer, base = [], current = [], count = 0, mask = 0) {
for (let i = 0; i < count; i++) if (mask & (1 << i)) writer.s((current?.[i] || 0) - (base?.[i] || 0));
}
function readDiffValues(reader, base = [], count = 0, mask = 0) {
const out = Array.from({ length: count }, (_, i) => base?.[i] || 0);
for (let i = 0; i < count; i++) if (mask & (1 << i)) out[i] = (base?.[i] || 0) + reader.s();
return out;
}
function writeTarinaiPayload(writer, row = [], meta = null, coordState = null, worldSeed = "", rowIndex = 0) {
const info = meta || analyzeTarinaiRow(row, worldSeed, rowIndex);
let birthSeed = "";
writer.category("tarinaiSeed", () => { birthSeed = writeBirthDescriptor(writer, row[1], worldSeed, rowIndex + 1); });
writer.addDetail("birthSeedRawUtf8Bytes", utf8Length(birthSeed));
const x = toSafeInt(row[2] || 0);
const y = toSafeInt(row[3] || 0);
const age = toSafeInt(row[4] || 0);
const generation = toSafeInt(row[5] || 1);
writer.s(x - (coordState?.x || 0));
writer.s(y - (coordState?.y || 0));
writer.s(age - (coordState?.age || 0));
writer.s(generation - (coordState?.generation || 1));
if (coordState) { coordState.x = x; coordState.y = y; coordState.age = age; coordState.generation = generation; }
const defaultPersonality = seedPersonalityArray(birthSeed, info.flags);
if (info.bodyMask & 1) writeFixedDiffPacked(writer, defaultPersonality, row[10], 4, 8);
if (info.bodyMask & 2) {
writer.addDetail("parentLinks", (row[11] || []).length);
writer.category("tarinaiRelationships", () => writeIndexList(writer, row[11]));
}
if (info.bodyMask & 4) {
writer.addDetail("relationshipRows", (row[12] || []).length);
writer.category("tarinaiRelationships", () => writeRelationships(writer, row[12]));
}
if (info.bodyMask & 8) writeTimers(writer, row[13]);
if (info.bodyMask & 16) writeModes(writer, row[14]);
if (info.linkMask & 1) writer.u(info.pinIdx);
if (info.linkMask & 2) writer.u(info.nestIdx);
if (info.stateId) writer.s(info.stateTarget);
if (info.geneticsMask) writeDiffValues(writer, info.geneticsBase, info.genetics, 5, info.geneticsMask);
if (info.fightMask) writeDiffValues(writer, [0, 0, 0], info.fightStats, 3, info.fightMask);
}
function readTarinaiPayload(reader, header = {}, coordState = null, worldSeed = "", rowIndex = 0) {
const flags = header.flags || 0;
const birth = readBirthDescriptor(reader, worldSeed, rowIndex + 1);
const x = (coordState?.x || 0) + reader.s();
const y = (coordState?.y || 0) + reader.s();
const age = (coordState?.age || 0) + reader.s();
const generation = (coordState?.generation || 1) + reader.s();
if (coordState) { coordState.x = x; coordState.y = y; coordState.age = age; coordState.generation = generation; }
const hunger = header.hunger || 0;
const energy = header.energy || 0;
const sleepPressure = header.sleepPressure || 0;
const bodyMask = header.bodyMask || 0;
const linkMask = header.linkMask || 0;
const needs = defaultNeedsFromVitals(hunger, energy, sleepPressure);
const defaultPersonality = seedPersonalityArray(birth.seed, flags);
const current = (bodyMask & 1) ? readFixedDiffPacked(reader, defaultPersonality, 4, 8) : defaultPersonality;
const parents = (bodyMask & 2) ? readIndexList(reader) : [];
const relationships = (bodyMask & 4) ? readRelationships(reader) : [];
const timers = (bodyMask & 8) ? readTimers(reader) : [0, 0, 0, 0, 0, 0, 0, 0, 100, 0];
const modes = (bodyMask & 16) ? readModes(reader) : [0, 0, 0];
const pinIdx = (linkMask & 1) ? reader.u() : -1;
const nestIdx = (linkMask & 2) ? reader.u() : -1;
const stateInfo = header.stateId ? [header.stateId, reader.s()] : [0, -1];
const geneticsBase = seedGeneticsArray(birth.seed, flags);
const genetics = header.geneticsMask ? readDiffValues(reader, geneticsBase, 5, header.geneticsMask) : [];
const fightStats = header.fightMask ? readDiffValues(reader, [0, 0, 0], 3, header.fightMask) : [];
return [flags, birth.descriptor, x, y, age, generation, hunger, energy, sleepPressure, needs, current, parents, relationships, timers, modes, pinIdx, nestIdx, stateInfo, genetics, fightStats];
}
const PHYSICS_BODY_TYPES = new Set(["rotator", "poison_block", "reciprocator"]);
const PHYSICS_CONSTRAINT_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
function physicsTypeFromId(typeId = 0) {
return SaveSchema.itemTypeValue(Number(typeId) || 0, "");
}
function isPhysicsTypeId(typeId = 0) {
const type = physicsTypeFromId(typeId);
return PHYSICS_BODY_TYPES.has(type) || PHYSICS_CONSTRAINT_TYPES.has(type);
}
function defaultPhysicsAngle(type = "") {
const fallback = type === "reciprocator" ? Math.PI / 2 : 0;
try {
return Math.round(Number(global.defaultItemAngle?.(type) ?? fallback) * 1000);
} catch (_) {
return Math.round(fallback * 1000);
}
}
function defaultPhysicsSegments(type = "") {
if (type === "poison_block") return [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]];
if (type === "rotator") return [[-78, 0, 78, 0], [0, -52, 0, 52]];
return [[-78, 0, 78, 0]];
}
function defaultPhysicsKind(type = "") {
if (type === "rotator") return "rotational";
if (type === "reciprocator") return "linear";
if (type === "poison_block") return "passive";
if (type === "rope") return "flexible-distance";
if (type === "rod") return "rigid-distance";
if (type === "spring") return "spring-distance";
if (type === "wire" || type === "insulated_wire") return "signal-wire";
return "none";
}
function defaultPhysicsBodyCompact(type = "", x = 0, y = 0) {
const angle = defaultPhysicsAngle(type);
return {
kind: defaultPhysicsKind(type),
pose: [x, y, angle],
vel: [0, 0, 0, 0],
motor: type === "rotator" ? [1, 20, 1] : (type === "reciprocator" ? [1, 920, 1] : [0, 0, 1]),
rail: type === "reciprocator" ? [angle, 1500, 0, x, y] : null,
shape: [type === "poison_block" ? 110 : 120, defaultPhysicsSegments(type)],
collision: type === "poison_block" ? [1, 1] : [1, 0],
hazard: type === "poison_block" ? ["poison", 70] : null,
mass: type === "poison_block" ? 5 : 10,
inertia: type === "poison_block" ? 3200 : 22000,
};
}
function arraysEqualNumbers(a = [], b = []) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if ((Number(a[i]) || 0) !== (Number(b[i]) || 0)) return false;
return true;
}
function segmentsEqual(a = [], b = []) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (!arraysEqualNumbers(a[i], b[i])) return false;
return true;
}
function writeChangedSignedArray(writer, current = [], defaults = [], count = 0) {
let mask = 0;
for (let i = 0; i < count; i++) if ((Number(current?.[i]) || 0) !== (Number(defaults?.[i]) || 0)) mask |= (1 << i);
writeBitFields(writer, [mask], [count]);
for (let i = 0; i < count; i++) if (mask & (1 << i)) writer.s(current?.[i] || 0);
}
function readChangedSignedArray(reader, defaults = [], count = 0) {
const [mask] = readBitFields(reader, [count]);
const out = Array.from({ length: count }, (_, i) => Number(defaults?.[i]) || 0);
for (let i = 0; i < count; i++) if (mask & (1 << i)) out[i] = reader.s();
return out;
}
function writePhysicsSegments(writer, segments = []) {
const list = Array.isArray(segments) ? segments.slice(0, 128) : [];
writer.u(list.length);
const previous = [0, 0, 0, 0];
for (const segment of list) {
for (let i = 0; i < 4; i++) {
const value = toSafeInt(segment?.[i] || 0);
writer.s(value - previous[i]);
previous[i] = value;
}
}
}
function readPhysicsSegments(reader) {
const count = Math.min(128, reader.u());
const out = [];
const previous = [0, 0, 0, 0];
for (let n = 0; n < count; n++) {
const segment = [];
for (let i = 0; i < 4; i++) {
previous[i] += reader.s();
segment.push(previous[i]);
}
out.push(segment);
}
return out;
}
function writePhysicsBodyExtra(writer, extra, type, row = []) {
const body = extra?.body && typeof extra.body === "object" ? extra.body : {};
const x = toSafeInt(row?.[1] || 0);
const y = toSafeInt(row?.[2] || 0);
const defaults = defaultPhysicsBodyCompact(type, x, y);
const pose = Array.isArray(body.pose) ? body.pose : defaults.pose;
const vel = Array.isArray(body.vel) ? body.vel : defaults.vel;
const motor = Array.isArray(body.motor) ? body.motor : defaults.motor;
const rail = type === "reciprocator" ? (Array.isArray(body.rail) ? body.rail : defaults.rail) : null;
const shape = Array.isArray(body.shape) ? body.shape : defaults.shape;
const collision = Array.isArray(body.collision) ? body.collision : defaults.collision;
const hazard = type === "poison_block" ? (Array.isArray(body.hazard) ? body.hazard : defaults.hazard) : null;
const kind = String(body.kind || defaults.kind);
let mask = 0;
if ((Number(pose[0]) || 0) !== x || (Number(pose[1]) || 0) !== y) mask |= 1;
if ((Number(pose[2]) || 0) !== defaults.pose[2]) mask |= 2;
if (!arraysEqualNumbers(vel, defaults.vel)) mask |= 4;
if (!arraysEqualNumbers(motor, defaults.motor)) mask |= 8;
if (type === "reciprocator" && !arraysEqualNumbers(rail, defaults.rail)) mask |= 16;
if ((Number(shape?.[0]) || 0) !== defaults.shape[0]) mask |= 32;
if (!segmentsEqual(shape?.[1], defaults.shape[1])) mask |= 64;
if (!arraysEqualNumbers(collision, defaults.collision)) mask |= 128;
if (type === "poison_block" && (!hazard || String(hazard[0] || "") !== defaults.hazard[0] || (Number(hazard[1]) || 0) !== defaults.hazard[1])) mask |= 256;
if ((Number(body.mass) || 0) !== defaults.mass) mask |= 512;
if ((Number(body.inertia) || 0) !== defaults.inertia) mask |= 1024;
if (kind !== defaults.kind) mask |= 2048;
writer.u(mask);
if (mask & 1) { writer.s((Number(pose[0]) || 0) - x); writer.s((Number(pose[1]) || 0) - y); }
if (mask & 2) writer.s(pose[2] || 0);
if (mask & 4) writeChangedSignedArray(writer, vel, defaults.vel, 4);
if (mask & 8) writeChangedSignedArray(writer, motor, defaults.motor, 3);
if (mask & 16) writeChangedSignedArray(writer, rail, defaults.rail, 5);
if (mask & 32) writer.u(shape?.[0] || 0);
if (mask & 64) writePhysicsSegments(writer, shape?.[1] || []);
if (mask & 128) writeBitFields(writer, [collision?.[0] ? 1 : 0, collision?.[1] ? 1 : 0], [1, 1]);
if (mask & 256) {
const hazardKind = String(hazard?.[0] || "poison");
const kindChanged = hazardKind !== defaults.hazard[0];
writeBitFields(writer, [kindChanged ? 1 : 0], [1]);
if (kindChanged) writer.str(hazardKind);
writer.u(hazard?.[1] || 0);
}
if (mask & 512) writer.u(body.mass || 0);
if (mask & 1024) writer.u(body.inertia || 0);
if (mask & 2048) writer.str(kind);
}
function readPhysicsBodyExtra(reader, type, row = []) {
const x = toSafeInt(row?.[1] || 0);
const y = toSafeInt(row?.[2] || 0);
const defaults = defaultPhysicsBodyCompact(type, x, y);
const mask = reader.u();
const pose = defaults.pose.slice();
if (mask & 1) { pose[0] = x + reader.s(); pose[1] = y + reader.s(); }
if (mask & 2) pose[2] = reader.s();
const vel = (mask & 4) ? readChangedSignedArray(reader, defaults.vel, 4) : defaults.vel.slice();
const motor = (mask & 8) ? readChangedSignedArray(reader, defaults.motor, 3) : defaults.motor.slice();
const rail = type === "reciprocator" ? ((mask & 16) ? readChangedSignedArray(reader, defaults.rail, 5) : defaults.rail.slice()) : null;
const thickness = (mask & 32) ? reader.u() : defaults.shape[0];
const segments = (mask & 64) ? readPhysicsSegments(reader) : defaults.shape[1].map(segment => segment.slice());
const collision = (mask & 128) ? readBitFields(reader, [1, 1]) : defaults.collision.slice();
let hazard = defaults.hazard ? defaults.hazard.slice() : null;
if (mask & 256) {
const [kindChanged] = readBitFields(reader, [1]);
const kind = kindChanged ? reader.str() : defaults.hazard[0];
hazard = [kind, reader.u()];
}
const mass = (mask & 512) ? reader.u() : defaults.mass;
const inertia = (mask & 1024) ? reader.u() : defaults.inertia;
const kind = (mask & 2048) ? reader.str() : defaults.kind;
return { pf: 2, body: { type, kind, pose, vel, motor, rail, shape: [thickness, segments], collision, hazard, mass, inertia } };
}
function writePhysicsEndpoint(writer, endpoint, row = []) {
if (!endpoint) { writeBitFields(writer, [0], [1]); return; }
writeBitFields(writer, [1], [1]);
const kind = String(endpoint.kind || "item");
const kindCode = kind === "item" ? 0 : (kind === "tarinai" ? 1 : 2);
writeBitFields(writer, [kindCode], [2]);
if (kindCode === 2) writer.str(kind);
const refIdx = Number.isInteger(Number(endpoint.refIdx)) ? Number(endpoint.refIdx) : -1;
writer.u(refIdx >= 0 ? refIdx + 1 : 0);
const indexed = refIdx >= 0;
const tokenPresent = !indexed && endpoint.token != null && Number.isFinite(Number(endpoint.token));
let mask = 0;
// A valid item/Tarinai index is the canonical reference. Text identity,
// live tokens and fallback world coordinates are only needed when the
// endpoint target is absent from the save.
if (!indexed && endpoint.id) mask |= 1;
if (!indexed && endpoint.type) mask |= 2;
if (!indexed && endpoint.label) mask |= 4;
if (tokenPresent) mask |= 8;
if (Number(endpoint.lx) || 0) mask |= 16;
if (Number(endpoint.ly) || 0) mask |= 32;
if (endpoint.center) mask |= 64;
if (!indexed && (Number(endpoint.x) || 0) !== (Number(row?.[1]) || 0)) mask |= 128;
if (!indexed && (Number(endpoint.y) || 0) !== (Number(row?.[2]) || 0)) mask |= 256;
if (endpoint.t != null && Number.isFinite(Number(endpoint.t))) mask |= 512;
if (endpoint.port) mask |= 1024;
writer.u(mask);
if (mask & 1) writer.str(endpoint.id);
if (mask & 2) writer.str(endpoint.type);
if (mask & 4) writer.str(endpoint.label);
if (mask & 8) writer.u(endpoint.token);
if (mask & 16) writer.s(endpoint.lx);
if (mask & 32) writer.s(endpoint.ly);
if (mask & 128) writer.s((Number(endpoint.x) || 0) - (Number(row?.[1]) || 0));
if (mask & 256) writer.s((Number(endpoint.y) || 0) - (Number(row?.[2]) || 0));
if (mask & 512) writer.u(endpoint.t);
if (mask & 1024) writer.str(endpoint.port);
}
function readPhysicsEndpoint(reader, row = []) {
const [present] = readBitFields(reader, [1]);
if (!present) return null;
const [kindCode] = readBitFields(reader, [2]);
const kind = kindCode === 0 ? "item" : (kindCode === 1 ? "tarinai" : reader.str());
const encodedRef = reader.u();
const refIdx = encodedRef ? encodedRef - 1 : -1;
const mask = reader.u();
const id = (mask & 1) ? reader.str() : "";
const type = (mask & 2) ? reader.str() : "";
const label = (mask & 4) ? reader.str() : "";
const token = (mask & 8) ? reader.u() : null;
const lx = (mask & 16) ? reader.s() : 0;
const ly = (mask & 32) ? reader.s() : 0;
const center = (mask & 64) ? 1 : 0;
const x = (Number(row?.[1]) || 0) + ((mask & 128) ? reader.s() : 0);
const y = (Number(row?.[2]) || 0) + ((mask & 256) ? reader.s() : 0);
const t = (mask & 512) ? reader.u() : null;
const port = (mask & 1024) ? reader.str() : "";
return { kind, refIdx, id, type, label, token, lx, ly, center, x, y, t, port };
}
function writePhysicsConstraintExtra(writer, extra, type, row = []) {
const constraint = extra?.constraint && typeof extra.constraint === "object" ? extra.constraint : {};
const defaultKind = defaultPhysicsKind(type);
const kind = String(constraint.kind || defaultKind);
const mid = Array.isArray(constraint.mid) ? constraint.mid : [row?.[1] || 0, (row?.[2] || 0) + 10, 0, 0];
let mask = 0;
if (kind !== defaultKind) mask |= 1;
if ((Number(constraint.length) || 80) !== 80) mask |= 2;
if ((Number(mid[0]) || 0) !== (Number(row?.[1]) || 0) || (Number(mid[1]) || 0) !== (Number(row?.[2]) || 0) || (Number(mid[2]) || 0) !== 0 || (Number(mid[3]) || 0) !== 0) mask |= 4;
writer.u(mask);
if (mask & 1) writer.str(kind);
writePhysicsEndpoint(writer, constraint.endpoints?.[0], row);
writePhysicsEndpoint(writer, constraint.endpoints?.[1], row);
if (mask & 2) writer.u(constraint.length || 0);
if (mask & 4) {
const defaults = [row?.[1] || 0, (row?.[2] || 0) + 10, 0, 0];
writeChangedSignedArray(writer, mid, defaults, 4);
}
}
function readPhysicsConstraintExtra(reader, type, row = []) {
const mask = reader.u();
const kind = (mask & 1) ? reader.str() : defaultPhysicsKind(type);
const endpoints = [readPhysicsEndpoint(reader, row), readPhysicsEndpoint(reader, row)];
const length = (mask & 2) ? reader.u() : 80;
const defaults = [row?.[1] || 0, (row?.[2] || 0) + 10, 0, 0];
const mid = (mask & 4) ? readChangedSignedArray(reader, defaults, 4) : defaults;
return { pf: 2, constraint: { type, kind, endpoints, length, mid } };
}
function writePhysicsItemExtra(writer, extra, typeId = 0, row = []) {
const type = physicsTypeFromId(typeId);
const valid = extra && typeof extra === "object" && Number(extra.pf) === 2 && (extra.body || extra.constraint);
writeBitFields(writer, [valid ? 1 : 0], [1]);
if (!valid) return;
if (PHYSICS_BODY_TYPES.has(type)) writePhysicsBodyExtra(writer, extra, type, row);
else writePhysicsConstraintExtra(writer, extra, type, row);
}
function readPhysicsItemExtra(reader, typeId = 0, row = []) {
const [valid] = readBitFields(reader, [1]);
if (!valid) return { pf: 2, missing: true };
const type = physicsTypeFromId(typeId);
if (PHYSICS_BODY_TYPES.has(type)) return readPhysicsBodyExtra(reader, type, row);
return readPhysicsConstraintExtra(reader, type, row);
}
const STRUCTURE_ITEM_TYPES = new Set(["grass_bed", "plushie"]);
function normalTypeFromId(typeId = 0) { return SaveSchema.itemTypeValue(Number(typeId) || 0, ""); }
function isPinSaveType(type = "") { return type === "pushpin" || type === "oshibyo" || Boolean(global.isPinType?.(type)); }
function isRotatableSaveType(type = "") { return Boolean(global.isRotatableItemType?.(type)); }
function isServingFoodSaveType(type = "") { return Boolean(global.isServingFoodType?.(type)); }
function defaultAngleMilli(type = "") {
try { return Math.round(Number(global.defaultItemAngle?.(type) || 0) * 1000); } catch (_) { return 0; }
}
function writeFixedSignedDefaults(writer, values = [], defaults = [], count = 0) {
let mask = 0;
for (let i = 0; i < count; i++) if ((Number(values?.[i]) || 0) !== (Number(defaults?.[i]) || 0)) mask |= (1 << i);
writeBitFields(writer, [mask], [count]);
for (let i = 0; i < count; i++) if (mask & (1 << i)) writer.s(values?.[i] || 0);
}
function readFixedSignedDefaults(reader, defaults = [], count = 0) {
const [mask] = readBitFields(reader, [count]);
const out = Array.from({ length: count }, (_, i) => Number(defaults?.[i]) || 0);
for (let i = 0; i < count; i++) if (mask & (1 << i)) out[i] = reader.s();
return out;
}
function writeGenericExtra(writer, extra = []) {
const list = Array.isArray(extra) ? extra : [];
writer.u(list.length);
for (const value of list) {
if (typeof value === "string") { writer.b(1); writeSaveString(writer, value); }
else { writer.b(0); writer.s(value || 0); }
}
}
function readGenericExtra(reader) {
const count = reader.u();
const out = [];
for (let i = 0; i < count; i++) out.push(reader.b() === 1 ? readSaveString(reader) : reader.s());
return out;
}
function writeNormalItemExtra(writer, extra = [], typeId = 0, row = []) {
const type = normalTypeFromId(typeId);
const list = Array.isArray(extra) ? extra : [];
if (STRUCTURE_ITEM_TYPES.has(type)) {
const defaultMax = type === "plushie" ? 10 : 1000;
// hp is identical to row amount and is reconstructed instead of stored.
writeFixedSignedDefaults(writer, [list[1], list[2], list[3], list[4], list[5], list[6]], [defaultMax, -1, -1, 0, -1, 0], 6);
} else if (type === "grass") writeFixedSignedDefaults(writer, list, [100, 100, 0, 0], 4);
else if (type === "zunchi") writeFixedSignedDefaults(writer, list, [0, 100, 100], 3);
else if (type === "signboard") writeSaveString(writer, list[0] || "");
else if (type === "robot_cleaner") writeFixedSignedDefaults(writer, list, [3, 0], 2);
else if (type === "circuit_board") writeSaveString(writer, list[0] || "{}");
else if (type === "duplicator") writeFixedSignedDefaults(writer, list, [-1], 1);
else if (type === "ball" || type === "balloon") writeFixedSignedDefaults(writer, list, [0, 0], 2);
else if (type === "ant_nest") writeFixedSignedDefaults(writer, list, [0, 0], 2);
else if (type === "ant_corpse" || type === "firecracker" || type === "flame_firecracker" || type === "fire") writeFixedSignedDefaults(writer, list, [0], 1);
else if (type === "sticky_bomb") writeFixedSignedDefaults(writer, list, [0, -1, 0], 3);
else if (type === "nest_box") writeFixedSignedDefaults(writer, list, [0, 0, 0, 0], 4);
else if (isPinSaveType(type)) writeFixedSignedDefaults(writer, list, [0, -1, 0, 0, 0], 5);
else if (type === "fan") {
const angle = defaultAngleMilli(type);
writeFixedSignedDefaults(writer, list, [angle, 0, angle, Math.round(35 * Math.PI / 180 * 1000), Math.round(48 * Math.PI / 180 * 1000), 0, angle], 7);
}
else if (type === "pressure_switch") writeFixedSignedDefaults(writer, list, [0, 0, 10, 220, 160, 360, 1080], 7);
else if (isRotatableSaveType(type)) writeFixedSignedDefaults(writer, list, [defaultAngleMilli(type), 0], 2);
else if (isServingFoodSaveType(type)) writeFixedSignedDefaults(writer, list, [row?.[3] || 0, row?.[3] || 0], 2);
else writeGenericExtra(writer, list);
}
function readNormalItemExtra(reader, typeId = 0, row = []) {
const type = normalTypeFromId(typeId);
if (STRUCTURE_ITEM_TYPES.has(type)) {
const defaultMax = type === "plushie" ? 10 : 1000;
const rest = readFixedSignedDefaults(reader, [defaultMax, -1, -1, 0, -1, 0], 6);
return [row?.[3] || 0, ...rest];
}
if (type === "grass") return readFixedSignedDefaults(reader, [100, 100, 0, 0], 4);
if (type === "zunchi") return readFixedSignedDefaults(reader, [0, 100, 100], 3);
if (type === "signboard") return [readSaveString(reader)];
if (type === "robot_cleaner") return readFixedSignedDefaults(reader, [3, 0], 2);
if (type === "circuit_board") return [readSaveString(reader)];
if (type === "duplicator") return readFixedSignedDefaults(reader, [-1], 1);
if (type === "ball" || type === "balloon") return readFixedSignedDefaults(reader, [0, 0], 2);
if (type === "ant_nest") return readFixedSignedDefaults(reader, [0, 0], 2);
if (type === "ant_corpse" || type === "firecracker" || type === "flame_firecracker" || type === "fire") return readFixedSignedDefaults(reader, [0], 1);
if (type === "sticky_bomb") return readFixedSignedDefaults(reader, [0, -1, 0], 3);
if (type === "nest_box") return readFixedSignedDefaults(reader, [0, 0, 0, 0], 4);
if (isPinSaveType(type)) return readFixedSignedDefaults(reader, [0, -1, 0, 0, 0], 5);
if (type === "fan") {
const angle = defaultAngleMilli(type);
return readFixedSignedDefaults(reader, [angle, 0, angle, Math.round(35 * Math.PI / 180 * 1000), Math.round(48 * Math.PI / 180 * 1000), 0, angle], 7);
}
if (type === "pressure_switch") return readFixedSignedDefaults(reader, [0, 0, 10, 220, 160, 360, 1080], 7);
if (isRotatableSaveType(type)) return readFixedSignedDefaults(reader, [defaultAngleMilli(type), 0], 2);
if (isServingFoodSaveType(type)) return readFixedSignedDefaults(reader, [row?.[3] || 0, row?.[3] || 0], 2);
return readGenericExtra(reader);
}
function extraKey(value) {
try { return JSON.stringify(value == null ? [] : value); } catch (_) { return "[]"; }
}
function itemCoordKey(row = []) {
const x = Math.max(0, Math.min(65535, toSafeUInt(row[1] || 0)));
const y = Math.max(0, Math.min(65535, toSafeUInt(row[2] || 0)));
return mortonKey(x, y);
}
function mortonKey(x = 0, y = 0) {
let out = 0;
for (let i = 0; i < 16; i++) out += (((x >> i) & 1) * (2 ** (2 * i))) + (((y >> i) & 1) * (2 ** (2 * i + 1)));
return out;
}
function writeEncodedItemExtra(writer, row = [], typeId = 0) {
const physics = isPhysicsTypeId(typeId);
const before = writer.length;
if (physics) writePhysicsItemExtra(writer, row[4], typeId, row);
else writeNormalItemExtra(writer, row[4] || [], typeId, row);
writer.addDetail(physics ? "physicsItemExtraEncodedBytes" : "normalItemExtraEncodedBytes", writer.length - before);
}
function readEncodedItemExtra(reader, row = [], typeId = 0) {
return isPhysicsTypeId(typeId) ? readPhysicsItemExtra(reader, typeId, row) : readNormalItemExtra(reader, typeId, row);
}
function encodedItemExtraLength(row = [], typeId = 0) {
const scratch = new BinaryWriter();
writeEncodedItemExtra(scratch, row, typeId);
return scratch.length;
}
function itemBlocksFromRows(rows = []) {
const groups = new Map();
for (const row of rows || []) {
const typeId = row?.[0] || 0;
if (!groups.has(typeId)) groups.set(typeId, []);
groups.get(typeId).push(row);
}
const blocks = [];
for (const [typeId, list] of [...groups.entries()].sort((a, b) => a[0] - b[0])) {
list.sort((a, b) => itemCoordKey(a) - itemCoordKey(b) || (a[1] || 0) - (b[1] || 0) || (a[2] || 0) - (b[2] || 0));
blocks.push([typeId, list]);
}
return blocks;
}
function standardItemAmount(typeId = 0) {
const type = SaveSchema.itemTypeValue(Number(typeId) || 0, "");
const structureDef = global.StructureRegistry?.get?.(type);
if (Number.isFinite(Number(structureDef?.maxHp))) return toSafeInt(Number(structureDef.maxHp) * 10);
const toolDef = typeof global.itemDefinition === "function" ? global.itemDefinition(type) : null;
if (Number.isFinite(Number(toolDef?.amount))) return toSafeInt(Number(toolDef.amount) * 10);
return 0;
}
function signedVarintLength(value = 0) {
return encodeVarUint(zigzagEncode(value)).length;
}
function chooseAmountPlan(typeId = 0, list = []) {
const standardAmount = standardItemAmount(typeId);
const amounts = list.map(row => toSafeInt(row?.[3] || 0));
const candidates = new Set([standardAmount, ...amounts]);
let best = null;
for (const candidate of candidates) {
let payloadBytes = candidate === standardAmount ? 0 : signedVarintLength(candidate - standardAmount);
for (const amount of amounts) if (amount !== candidate) payloadBytes += signedVarintLength(amount - candidate);
const plan = {
standardAmount,
commonAmount: candidate,
useCustomAmountBase: candidate !== standardAmount,
costBytes: payloadBytes,
};
if (!best || plan.costBytes < best.costBytes || (plan.costBytes === best.costBytes && !plan.useCustomAmountBase && best.useCustomAmountBase)) best = plan;
}
return best || { standardAmount, commonAmount: standardAmount, useCustomAmountBase: false, costBytes: 0 };
}
function chooseCommonExtraPlan(typeId = 0, list = [], commonAmount = 0) {
if (isPhysicsTypeId(typeId) || !list.length) return { useCommonExtra: false, commonExtra: [], commonKey: "", costBits: Infinity };
const entries = list.map(row => ({
row,
key: extraKey(row?.[4]),
value: row?.[4] == null ? [] : row[4],
encodedBytes: encodedItemExtraLength(row, typeId),
}));
const noCommonBits = entries.reduce((sum, entry) => sum + entry.encodedBytes * 8, 0);
let best = { useCommonExtra: false, commonExtra: [], commonKey: "", costBits: noCommonBits };
const candidates = new Map();
for (const entry of entries) if (!candidates.has(entry.key)) candidates.set(entry.key, entry.value);
for (const [key, value] of candidates) {
const commonRow = [typeId, 0, 0, commonAmount, value];
let payloadBytes = encodedItemExtraLength(commonRow, typeId);
for (const entry of entries) if (entry.key !== key) payloadBytes += entry.encodedBytes;
const costBits = payloadBytes * 8 + entries.length; // one extra-different flag per item
if (costBits < best.costBits) best = { useCommonExtra: true, commonExtra: value, commonKey: key, costBits };
}
return best;
}
function prepareItemBlocks(rows = []) {
return itemBlocksFromRows(rows).map(([typeId, list]) => {
const physics = isPhysicsTypeId(typeId);
const amountPlan = chooseAmountPlan(typeId, list);
const extraPlan = chooseCommonExtraPlan(typeId, list, amountPlan.commonAmount);
const rowPlans = list.map(row => ({
row,
amountDifferent: toSafeInt(row?.[3] || 0) !== amountPlan.commonAmount,
extraDifferent: extraPlan.useCommonExtra && extraKey(row?.[4]) !== extraPlan.commonKey,
}));
return { typeId, list, physics, category: physics ? "physicsItems" : "normalItems", ...amountPlan, ...extraPlan, rowPlans };
});
}
function writeItemBlocks(writer, rows = []) {
const blocks = prepareItemBlocks(rows);
writer.category("itemIndex", () => writer.u(blocks.length));
for (const block of blocks) {
writer.category("itemIndex", () => { writer.u(block.typeId); writer.u(block.list.length); });
}
const flags = new CompactBitWriter();
for (const block of blocks) {
flags.write(block.useCustomAmountBase ? 1 : 0, 1);
flags.write(block.useCommonExtra ? 1 : 0, 1);
}
for (const block of blocks) {
for (const plan of block.rowPlans) {
flags.write(plan.amountDifferent ? 1 : 0, 1);
if (block.useCommonExtra) flags.write(plan.extraDifferent ? 1 : 0, 1);
}
}
const flagBytes = flags.finish();
writer.category("itemIndex", () => { writer.u(flagBytes.length); writer.raw(flagBytes); });
writer.setDetail("itemFlagPlaneBytes", flagBytes.length);
for (const block of blocks) {
writer.category(block.category, () => {
if (block.useCustomAmountBase) writer.s(block.commonAmount - block.standardAmount);
if (block.useCommonExtra) writeEncodedItemExtra(writer, [block.typeId, 0, 0, block.commonAmount, block.commonExtra], block.typeId);
const coordState = { x: 0, y: 0 };
for (const plan of block.rowPlans) {
const row = plan.row;
writer.addDetail(block.physics ? "physicsItemCount" : "normalItemCount", 1);
const x = toSafeInt(row[1] || 0);
const y = toSafeInt(row[2] || 0);
writer.s(x - coordState.x);
writer.s(y - coordState.y);
coordState.x = x; coordState.y = y;
if (plan.amountDifferent) writer.s(toSafeInt(row[3] || 0) - block.commonAmount);
if (!block.useCommonExtra || plan.extraDifferent) writeEncodedItemExtra(writer, row, block.typeId);
if (writer.stats) {
const rawJson = JSON.stringify(row[4] == null ? [] : row[4]);
writer.addDetail(block.physics ? "physicsItemExtraRawUtf8Bytes" : "normalItemExtraRawUtf8Bytes", utf8Length(rawJson));
}
}
});
}
}
function readItemBlocks(reader) {
const blockCount = reader.u();
const blocks = [];
for (let b = 0; b < blockCount; b++) {
const typeId = reader.u();
const count = reader.u();
blocks.push({
typeId,
count,
standardAmount: standardItemAmount(typeId),
commonAmount: 0,
useCustomAmountBase: false,
useCommonExtra: false,
rowFlags: [],
});
}
const flagByteLength = reader.u();
const flags = new CompactBitReader(reader.raw(flagByteLength));
for (const block of blocks) {
block.useCustomAmountBase = Boolean(flags.read(1));
block.useCommonExtra = Boolean(flags.read(1));
}
for (const block of blocks) {
block.rowFlags = Array.from({ length: block.count }, () => ({
amountDifferent: Boolean(flags.read(1)),
extraDifferent: block.useCommonExtra ? Boolean(flags.read(1)) : false,
}));
}
const items = [];
for (const block of blocks) {
const { typeId, count, standardAmount, useCustomAmountBase, useCommonExtra } = block;
const commonAmount = standardAmount + (useCustomAmountBase ? reader.s() : 0);
const commonRow = [typeId, 0, 0, commonAmount, null];
if (useCommonExtra) commonRow[4] = readEncodedItemExtra(reader, commonRow, typeId);
const coordState = { x: 0, y: 0 };
for (let i = 0; i < count; i++) {
const x = coordState.x + reader.s();
const y = coordState.y + reader.s();
coordState.x = x; coordState.y = y;
const rowFlags = block.rowFlags[i];
const amount = commonAmount + (rowFlags.amountDifferent ? reader.s() : 0);
const row = [typeId, x, y, amount, null];
row[4] = useCommonExtra && !rowFlags.extraDifferent ? commonRow[4] : readEncodedItemExtra(reader, row, typeId);
items.push(row);
}
}
return items;
}
const ACHIEVEMENT_METADATA_BINARY_VERSION = 3;
const ACHIEVEMENT_SPELL_BINARY_VERSION = 11;
const ACHIEVEMENT_MASK_BYTES = 10;
const ACHIEVEMENT_PROGRESS_BITS_V9 = Object.freeze([7, 4, 7, 12, 5, 5, 5, 11, 6, 10, 9, 10, 3, 17, 5, 14, 7]);
const ACHIEVEMENT_PROGRESS_MAX_V9 = Object.freeze([100, 15, 100, 3333, 30, 20, 20, 2047, 50, 666, 333, 721, 7, 131071, 30, 10000, 100]);
const ACHIEVEMENT_PROGRESS_BITS = Object.freeze([...ACHIEVEMENT_PROGRESS_BITS_V9, 4, 6, 8]);
const ACHIEVEMENT_PROGRESS_MAX = Object.freeze([...ACHIEVEMENT_PROGRESS_MAX_V9, 10, 33, 200]);
function finiteNumberOrNull(value) {
if (value === null || value === undefined) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function quantizedTenths(value = 0) { return Math.round((Number(value) || 0) * 10); }
function unquantizedTenths(value = 0) { return (Number(value) || 0) / 10; }
function varUintLength(value = 0) { return encodeVarUint(value).length; }
function writePositiveDeltaList(writer, values = []) {
const list = [...new Set((Array.isArray(values) ? values : []).map(value => Math.max(1, toSafeUInt(value))))].sort((a, b) => a - b);
writer.u(list.length);
let previous = 0;
for (const value of list) { writer.u(value - previous); previous = value; }
}
function readPositiveDeltaList(reader) {
const count = reader.u();
const out = [];
let previous = 0;
for (let i = 0; i < count; i++) { previous += reader.u(); out.push(previous); }
return out;
}
function achievementMaskBytesFromSpell(spell = null) {
const bytes = new Uint8Array(ACHIEVEMENT_MASK_BYTES);
if (!Array.isArray(spell)) return { bytes, baseMinute: 0, deltas: [], progress: [], mysteryDrugIds: [] };
const version = Number(spell[0]);
if (![9, 10, ACHIEVEMENT_SPELL_BINARY_VERSION].includes(version)) throw new Error("unsupported achievement spell version");
const low = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[1]) || 0)));
const high = Math.max(0, Math.min(0xffffffff, Math.floor(Number(spell[2]) || 0)));
const extra = Math.max(0, Math.min(0xffff, Math.floor(Number(spell[3]) || 0)));
for (let i = 0; i < 4; i++) bytes[i] = Math.floor(low / (2 ** (i * 8))) & 255;
for (let i = 0; i < 4; i++) bytes[i + 4] = Math.floor(high / (2 ** (i * 8))) & 255;
bytes[8] = extra & 255;
bytes[9] = (extra >>> 8) & 255;
const baseMinute = toSafeUInt(spell[4]);
const deltas = Array.isArray(spell[5]) ? spell[5].map(toSafeUInt) : [];
const progress = Array.isArray(spell[6]) ? spell[6] : [];
const mysteryDrugIds = version >= 10 && Array.isArray(spell[7])
? [...new Set(spell[7].map(value => String(value || "")).filter(Boolean))].slice(0, 10)
: [];
let unlockedCount = 0;
for (const byte of bytes) {
let value = byte;
while (value) { unlockedCount += value & 1; value >>>= 1; }
}
if (deltas.length !== unlockedCount) throw new Error("invalid achievement spell timestamps");
return { bytes, baseMinute, deltas, progress, mysteryDrugIds };
}
function spellFromAchievementMaskBytes(bytes = [], baseMinute = 0, deltas = [], progress = [], version = ACHIEVEMENT_SPELL_BINARY_VERSION, mysteryDrugIds = []) {
let low = 0;
let high = 0;
for (let i = 0; i < 4; i++) low += (Number(bytes[i]) || 0) * (2 ** (i * 8));
for (let i = 0; i < 4; i++) high += (Number(bytes[i + 4]) || 0) * (2 ** (i * 8));
const spell = [version, low >>> 0, high >>> 0, (Number(bytes[8]) || 0) + (Number(bytes[9]) || 0) * 256, toSafeUInt(baseMinute), deltas.map(toSafeUInt), progress];
if (version >= 10) spell.push([...new Set((mysteryDrugIds || []).map(value => String(value || "")).filter(Boolean))].slice(0, 10));
return spell;
}
function writeAchievementProgress(writer, values = [], bits = ACHIEVEMENT_PROGRESS_BITS, maxima = ACHIEVEMENT_PROGRESS_MAX) {
const normalized = maxima.map((max, index) => Math.max(0, Math.min(max, toSafeUInt(values?.[index]))));
let sparseMask = 0;
let sparseBytes = Math.ceil(maxima.length / 8) + 1;
normalized.forEach((value, index) => {
if (!value) return;
sparseMask |= 1 << index;
sparseBytes += varUintLength(value);
});
const fixedBytes = Math.ceil(bits.reduce((sum, width) => sum + width, 0) / 8);
const useSparse = sparseBytes < fixedBytes;
writer.b(useSparse ? 1 : 0);
if (useSparse) {
writeBitFields(writer, [sparseMask], [maxima.length]);
normalized.forEach((value, index) => { if (sparseMask & (1 << index)) writer.u(value); });
} else {
writeBitFields(writer, normalized, bits);
}
}
function readAchievementProgress(reader, bits = ACHIEVEMENT_PROGRESS_BITS, maxima = ACHIEVEMENT_PROGRESS_MAX) {
const sparse = Boolean(reader.b());
if (!sparse) return readBitFields(reader, bits);
const [mask] = readBitFields(reader, [maxima.length]);
return maxima.map((max, index) => mask & (1 << index) ? Math.min(max, reader.u()) : 0);
}
function writeAchievementSpell(writer, spell = null) {
const normalized = achievementMaskBytesFromSpell(spell);
writer.raw(normalized.bytes);
writer.u(normalized.baseMinute);
for (const delta of normalized.deltas) writer.u(delta);
writeAchievementProgress(writer, normalized.progress, ACHIEVEMENT_PROGRESS_BITS, ACHIEVEMENT_PROGRESS_MAX);
writer.u(normalized.mysteryDrugIds.length);
for (const id of normalized.mysteryDrugIds) writer.str(id);
}
function readAchievementSpell(reader, metadataVersion = ACHIEVEMENT_METADATA_BINARY_VERSION) {
const bytes = reader.raw(ACHIEVEMENT_MASK_BYTES);
let unlockedCount = 0;
for (const byte of bytes) {
let value = byte;
while (value) { unlockedCount += value & 1; value >>>= 1; }
}
const baseMinute = reader.u();
const deltas = [];
for (let i = 0; i < unlockedCount; i++) deltas.push(reader.u());
const current = metadataVersion >= 3;
const progress = readAchievementProgress(
reader,
current ? ACHIEVEMENT_PROGRESS_BITS : ACHIEVEMENT_PROGRESS_BITS_V9,
current ? ACHIEVEMENT_PROGRESS_MAX : ACHIEVEMENT_PROGRESS_MAX_V9,
);
const mysteryDrugIds = [];
if (current) {
const count = Math.min(10, reader.u());
for (let i = 0; i < count; i++) {
const id = String(reader.str() || "");
if (id && !mysteryDrugIds.includes(id)) mysteryDrugIds.push(id);
}
}
return spellFromAchievementMaskBytes(bytes, baseMinute, deltas, progress, current ? ACHIEVEMENT_SPELL_BINARY_VERSION : 9, mysteryDrugIds);
}
function writeAchievementMetadata(writer, metadata = null, tarinaiCount = 0, itemCount = 0) {
const value = metadata && typeof metadata === "object" ? metadata : {};
const w = Array.isArray(value.w) ? value.w : [];
writer.b(ACHIEVEMENT_METADATA_BINARY_VERSION);
writeBitFields(writer, [Array.isArray(value.s) ? 1 : 0], [1]);
writePositiveDeltaList(writer, w[0]);
writePositiveDeltaList(writer, w[1]);
writeBitFields(writer, [
Math.min(33, toSafeUInt(w[2])),
Math.min(200, toSafeUInt(w[3])),
Math.min(10, toSafeUInt(w[4])),
], [6, 8, 4]);
const timeDefaults = [null, -1, null, 0, -1, -1, -1];
const timeValues = [w[5], w[6], w[7], w[8], w[9], w[10], w[11]];
// Two-bit state per value: 0 = schema default, 1 = explicit null,
// 2 = explicit finite number. This preserves null separately from 0/-1.
const timeStates = [];
const encodedTimes = [];
timeValues.forEach((raw, index) => {
const finite = finiteNumberOrNull(raw);
const defaultValue = timeDefaults[index];
if (finite == null) {
timeStates.push(defaultValue == null ? 0 : 1);
return;
}
if (finite === defaultValue) {
timeStates.push(0);
return;
}
timeStates.push(2);
encodedTimes.push(quantizedTenths(finite));
});
writeBitFields(writer, timeStates, timeStates.map(() => 2));
encodedTimes.forEach(value => writer.s(value));
const tarRows = Array.isArray(value.t) ? value.t : [];
const activeTarRows = [];
for (let index = 0; index < Math.min(tarinaiCount, tarRows.length); index++) {
const row = Array.isArray(tarRows[index]) ? tarRows[index] : [];
const feed = toSafeUInt(row[0]);
const treatment = toSafeUInt(row[1]);
const fightAt = finiteNumberOrNull(row[2]);
const saunaAt = finiteNumberOrNull(row[3]);
const slaveDepth = toSafeUInt(row[4]);
const kingDepth = toSafeUInt(row[5]);
if (feed || treatment || fightAt != null || saunaAt != null || slaveDepth || kingDepth) {
activeTarRows.push({ index, feed, treatment, fightAt, saunaAt, slaveDepth, kingDepth });
}
}
writer.u(activeTarRows.length);
let previousTarIndex = -1;
for (const row of activeTarRows) { writer.u(row.index - previousTarIndex - 1); previousTarIndex = row.index; }
for (const row of activeTarRows) {
writer.u(row.feed);
writer.u(row.treatment);
writer.u(row.slaveDepth);
writer.u(row.kingDepth);
}
const tarFlags = [];
for (const row of activeTarRows) tarFlags.push(row.fightAt != null ? 1 : 0, row.saunaAt != null ? 1 : 0);
if (tarFlags.length) writeBitFields(writer, tarFlags, tarFlags.map(() => 1));
for (const row of activeTarRows) {
if (row.fightAt != null) writer.s(quantizedTenths(row.fightAt));
if (row.saunaAt != null) writer.s(quantizedTenths(row.saunaAt));
}
const itemRows = Array.isArray(value.i) ? value.i : [];
const activeItemRows = [];
for (let index = 0; index < Math.min(itemCount, itemRows.length); index++) {
const row = Array.isArray(itemRows[index]) ? itemRows[index] : [];
const placed = Boolean(row[0]);
const placedAt = finiteNumberOrNull(row[1]);
if (placed || placedAt != null) activeItemRows.push({ index, placed, placedAt });
}
writer.u(activeItemRows.length);
let previousItemIndex = -1;
for (const row of activeItemRows) { writer.u(row.index - previousItemIndex - 1); previousItemIndex = row.index; }
const itemFlags = [];
for (const row of activeItemRows) itemFlags.push(row.placed ? 1 : 0, row.placedAt != null ? 1 : 0);
if (itemFlags.length) writeBitFields(writer, itemFlags, itemFlags.map(() => 1));
for (const row of activeItemRows) if (row.placedAt != null) writer.s(quantizedTenths(row.placedAt));
if (Array.isArray(value.s)) writeAchievementSpell(writer, value.s);
}
function readAchievementMetadata(reader, tarinaiCount = 0, itemCount = 0) {
const version = reader.b();
if (![2, ACHIEVEMENT_METADATA_BINARY_VERSION].includes(version)) throw new Error("unsupported achievement metadata schema");
const [flags] = readBitFields(reader, [1]);
const w = [readPositiveDeltaList(reader), readPositiveDeltaList(reader)];
const [directFeed, robotClean, enemyAntKills] = readBitFields(reader, [6, 8, 4]);
w.push(directFeed, robotClean, enemyAntKills);
const timeDefaults = version >= 3 ? [null, -1, null, 0, -1, -1, -1] : [null, -1, null, 0, -1, -1];
const timeStates = readBitFields(reader, timeDefaults.map(() => 2));
for (let index = 0; index < timeDefaults.length; index++) {
const state = timeStates[index];
if (state === 0) w.push(timeDefaults[index]);
else if (state === 1) w.push(null);
else if (state === 2) w.push(unquantizedTenths(reader.s()));
else throw new Error("invalid achievement metadata time state");
}
const t = Array.from({ length: tarinaiCount }, () => [0, 0, null, null, 0, 0]);
const tarCount = reader.u();
const tarIndexes = [];
let previousTarIndex = -1;
for (let i = 0; i < tarCount; i++) {
previousTarIndex += reader.u() + 1;
if (previousTarIndex >= tarinaiCount) throw new Error("invalid achievement tarinai metadata index");
tarIndexes.push(previousTarIndex);
}
const tarCounters = tarIndexes.map(() => version >= 3
? [reader.u(), reader.u(), reader.u(), reader.u()]
: [reader.u(), reader.u(), 0, 0]);
const tarFlagWidths = Array.from({ length: tarCount * 2 }, () => 1);
const tarFlags = tarFlagWidths.length ? readBitFields(reader, tarFlagWidths) : [];
tarIndexes.forEach((index, rowIndex) => {
const fightAt = tarFlags[rowIndex * 2] ? unquantizedTenths(reader.s()) : null;
const saunaAt = tarFlags[rowIndex * 2 + 1] ? unquantizedTenths(reader.s()) : null;
t[index] = [
tarCounters[rowIndex][0],
tarCounters[rowIndex][1],
fightAt,
saunaAt,
tarCounters[rowIndex][2],
tarCounters[rowIndex][3],
];
});
const i = Array.from({ length: itemCount }, () => [0, null]);
const itemRowCount = reader.u();
const itemIndexes = [];
let previousItemIndex = -1;
for (let row = 0; row < itemRowCount; row++) {
previousItemIndex += reader.u() + 1;
if (previousItemIndex >= itemCount) throw new Error("invalid achievement item metadata index");
itemIndexes.push(previousItemIndex);
}
const itemFlagWidths = Array.from({ length: itemRowCount * 2 }, () => 1);
const itemFlags = itemFlagWidths.length ? readBitFields(reader, itemFlagWidths) : [];
itemIndexes.forEach((index, rowIndex) => {
const placed = itemFlags[rowIndex * 2] ? 1 : 0;
const placedAt = itemFlags[rowIndex * 2 + 1] ? unquantizedTenths(reader.s()) : null;
i[index] = [placed, placedAt];
});
const metadata = { w, t, i };
if (flags & 1) metadata.s = readAchievementSpell(reader, version);
return metadata;
}
function fieldValue(index = 0) { return FIELD_IDS[Number(index) | 0] || "garden"; }
function summaryFromPayload(w = [], tarinaiCount = 0) {
const config = typeof CONFIG !== "undefined" ? CONFIG : (global.CONFIG || {});
const dayLength = Math.max(1, Number(config.dayLength || 120));
const totalTime = Math.max(0, (Number(w[3]) || 0) / 10);
const explicitDays = Number(w[18]);
const elapsedDays = Number.isFinite(explicitDays)
? Math.max(0, Math.floor(explicitDays))
: Math.max(0, Math.floor(totalTime / dayLength));
return [elapsedDays + 1, tarinaiCount, fieldValue(w[0]), elapsedDays];
}
function encodeBinarySnapshot(snapshot, stats = null) {
if (!snapshot || snapshot.v !== Snapshot.version || snapshot.a !== "tj1") throw new Error("snapshot version mismatch");
const writer = new BinaryWriter(stats);
activeStringWriteMap = null;
try {
const stringTable = collectStringCounts(snapshot);
const hasStringTable = stringTable.length > 0;
writer.category("format", () => writer.b(BINARY_SCHEMA_VERSION | (hasStringTable ? 0x80 : 0)));
if (hasStringTable) writer.category("stringTable", () => writeStringTable(writer, stringTable));
else writer.setDetail("stringTableEntries", 0);
const worldRow = Array.isArray(snapshot.w) ? snapshot.w.slice() : [];
worldRow[8] = canonicalWorldSeed(worldRow[8] || "");
writer.category("world", () => writeWorld(writer, worldRow));
const tarinaiRows = Array.isArray(snapshot.t) ? snapshot.t : [];
writer.category("format", () => writer.u(tarinaiRows.length));
const tarCoordState = { x: 0, y: 0, age: 0, generation: 1 };
const worldSeed = worldRow[8];
const tarinaiMetas = tarinaiRows.map((row, index) => analyzeTarinaiRow(row || [], worldSeed, index));
writer.category("tarinaiBasic", () => writeTarinaiBitPlane(writer, tarinaiMetas));
tarinaiRows.forEach((row, index) => writer.category("tarinaiBasic", () => writeTarinaiPayload(writer, row || [], tarinaiMetas[index], tarCoordState, worldSeed, index)));
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
writeItemBlocks(writer, itemRows);
writer.category("achievementMetadata", () => writeAchievementMetadata(writer, snapshot.g || null, tarinaiRows.length, itemRows.length));
writer.category("extendedState", () => writer.str(JSON.stringify(snapshot.x || {})));
return writer.out();
} finally {
activeStringWriteMap = null;
}
}
function decodeBinarySnapshot(bytes) {
const reader = new BinaryReader(bytes);
activeStringReadList = null;
try {
const formatHeader = reader.b();
const schema = formatHeader & 0x7f;
const hasStringTable = Boolean(formatHeader & 0x80);
if (![54, BINARY_SCHEMA_VERSION].includes(schema)) throw new Error("unsupported binary save schema");
readStringTable(reader, hasStringTable);
const w = readWorld(reader);
const tCount = reader.u();
const t = [];
const tarCoordState = { x: 0, y: 0, age: 0, generation: 1 };
const tarinaiHeaders = readTarinaiBitPlane(reader, tCount);
for (let i = 0; i < tCount; i++) t.push(readTarinaiPayload(reader, tarinaiHeaders[i], tarCoordState, String(w?.[8] || ""), i));
const items = readItemBlocks(reader);
let g = null;
try {
g = readAchievementMetadata(reader, tCount, items.length);
} catch (_) {
throw new Error("invalid achievement metadata in binary save");
}
let x = {};
try { x = JSON.parse(reader.str() || "{}"); } catch (_) { throw new Error("invalid extended state in binary save"); }
if (reader.pos !== reader.bytes.length) throw new Error("trailing bytes in binary save");
return { v: Snapshot.version, a: "tj1", m: summaryFromPayload(w, t.length), w, t, i: items, r: [], g, x };
} finally {
activeStringReadList = null;
}
}
function finalizeEncodeStats(stats, rawBytes, frameInfo, hash, elapsedMs) {
if (!stats) return null;
for (const key of SAVE_STATS_SECTION_ORDER) stats.sections[key] = Number(stats.sections[key]) || 0;
const frame = frameInfo?.frame || new Uint8Array();
stats.rawBinaryBytes = rawBytes.length;
stats.binaryBytes = frame.length;
stats.compressed = Boolean(frameInfo?.compressed);
stats.compressedBytes = Number(frameInfo?.compressedBytes) || rawBytes.length;
stats.compressionSavedBytes = Math.max(0, rawBytes.length - stats.compressedBytes);
stats.hashChars = hash.length;
stats.payloadChars = Math.max(0, hash.length - EXPORT_PREFIX.length);
stats.encodeMs = Number(Math.max(0, elapsedMs).toFixed(3));
stats.accountedBytes = Object.values(stats.sections).reduce((sum, value) => sum + (Number(value) || 0), 0);
stats.unaccountedBytes = stats.rawBinaryBytes - stats.accountedBytes;
return stats;
}
function cloneStats(stats) {
if (!stats) return null;
return {
...stats,
sections: { ...(stats.sections || {}) },
details: { ...(stats.details || {}) },
};
}
function getLastEncodeStats() { return cloneStats(lastEncodeStats); }
function getLastDecodeStats() { return cloneStats(lastDecodeStats); }
async function encodeSnapshot(snapshot, options = {}) {
const collectStats = options?.collectStats === true || debugSaveStatsEnabled();
const stats = collectStats ? createSaveStats(snapshot) : null;
const startedAt = nowMs();
const rawBytes = encodeBinarySnapshot(snapshot, stats);
const frameInfo = await makeSaveFrame(rawBytes);
const hash = `${EXPORT_PREFIX}${base32768Encode(frameInfo.frame)}`;
if (stats) {
lastEncodeStats = finalizeEncodeStats(stats, rawBytes, frameInfo, hash, nowMs() - startedAt);
try {
global.dispatchEvent?.(new CustomEvent("tarinai-save-stats", { detail: cloneStats(lastEncodeStats) }));
} catch (_) {}
global.console?.info?.("[tarinai save stats]", cloneStats(lastEncodeStats));
}
return hash;
}
async function decodeSnapshot(text) {
const rawText = String(text || "").trim();
const collectStats = debugSaveStatsEnabled();
const startedAt = nowMs();
if (!rawText.startsWith(EXPORT_PREFIX)) throw new Error("unsupported Japanese save text");
const encoded = base32768Decode(rawText.slice(EXPORT_PREFIX.length));
const frame = await openSaveFrame(encoded);
const snapshot = decodeBinarySnapshot(frame.raw);
if (collectStats) {
lastDecodeStats = {
createdAt: Date.now(),
binaryBytes: frame.frameBytes,
rawBinaryBytes: frame.raw.length,
compressed: frame.compressed,
hashChars: rawText.length,
decodeMs: Number(Math.max(0, nowMs() - startedAt).toFixed(3)),
details: {
tarinaiCount: Array.isArray(snapshot?.t) ? snapshot.t.length : 0,
itemCount: Array.isArray(snapshot?.i) ? snapshot.i.length : 0,
},
};
try {
global.dispatchEvent?.(new CustomEvent("tarinai-load-stats", { detail: cloneStats(lastDecodeStats) }));
} catch (_) {}
}
return snapshot;
}
global.TarinaiSaveCodec = {
encodeSnapshot,
decodeSnapshot,
encodeBinarySnapshot,
decodeBinarySnapshot,
getLastEncodeStats,
getLastDecodeStats,
};
})(typeof window !== "undefined" ? window : globalThis);