1888 lines
84 KiB
JavaScript
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);
|