-- Private two-boot recovery integration probe. Executed ONLY in restored copies. -- "capture" runs in the hearth mod context before its real init; "probe" runs -- as a dependent worldmod after startup. No engine globals or live players mocked. return function(mode) local C = core local native_assert = assert local verified_assertions = 0 local failure_label = "checkpoint-probe-start" -- Assertion labels below are source-code constants, never engine/save input. -- Never expose pcall errors, original records or parser diagnostics. local function assert(value, label) label = label or "Unlabelled engine serialization" if not value then failure_label = "checkpoint-" .. label:lower():gsub("[^a-z]+", "-"):gsub("%-$", ""):sub(1, 96) C.log("error", "LAST_HEARTH_RECOVERY_FAIL " .. failure_label) return native_assert(value, failure_label) end verified_assertions = verified_assertions + 1 return value end local phase = C.settings:get("hearth_recovery_phase") local token = C.settings:get("hearth_recovery_token") local root = C.get_worldpath() local kinds = {"claims", "homes", "access", "location", "spawn"} assert(phase == "seed" or phase == "restart", "Invalid recovery phase") assert(token and token:match("^[%x%-]+$"), "Invalid recovery nonce") local function equal(a, b) if type(a) ~= type(b) then return false end if type(a) ~= "table" then return a == b end for k, v in pairs(a) do if not equal(v, b[k]) then return false end end for k in pairs(b) do if a[k] == nil then return false end end return true end local function decode(raw) local value = C.parse_json(raw, nil, true) assert(type(value) == "table", "Invalid recovery JSON") return value end local function read(name) local file = assert(io.open(root .. "/" .. name, "rb"), "Missing recovery evidence") local raw = file:read("*a") file:close() return decode(raw) end local function write(name, value) assert(C.safe_file_write(root .. "/" .. name, assert(C.write_json(value))), "Evidence write failed") end if mode == "capture" then assert(C.get_current_modname() == "hearth", "Capture requires real hearth storage context") if phase == "seed" then local storage = C.get_mod_storage() local expected = {format = 1, token = token, keys = {}, backups = {}} for _, kind in ipairs(kinds) do expected.keys[kind] = storage:get_string(kind) expected.backups[kind] = storage:get_string("m0_backup:" .. kind) end write("m0-recovery-expected.json", expected) else local expected = read("m0-recovery-expected.json") assert(expected.format == 1 and expected.token == token, "Original evidence missing on restart") end return end assert(mode == "probe", "Invalid recovery operation") local finished, ready = false, false local report = {format = 1, phase = phase, token = token, existingKeys = 0, legacyKeys = 0} -- The game's mapgen_limit is 512. Stay inside complete generatable chunks, -- high above terrain; refuse occupied space rather than overwrite any copy data. local fixture_pos = {x = 320, y = 320, z = 320} local expired_pos = {x = 321, y = 320, z = 320} local live_pos = {x = 322, y = 320, z = 320} local function no_clients() assert(#C.get_connected_players() == 0, "Recovery forbids live clients") end local function fail() if finished then return end finished = true -- Never include assertion/parser strings: original records may be private. C.log("error", "LAST_HEARTH_RECOVERY_FAIL " .. failure_label) C.request_shutdown("Private recovery failed", false, 0) end local function guard(fn) if finished then return end local ok = pcall(fn) if not ok then fail() end end local function envelope(kind, raw) local parsed = decode(raw) assert(parsed.schema == 1 and parsed.kind == kind and type(parsed.data) == "table", "Missing schema-1 envelope") local result, migrated = hearth.schema.decode(kind, parsed) assert(migrated == false and equal(result, parsed), "Envelope did not roundtrip") return parsed.data end local function world_check() failure_label = "checkpoint-world-check" assert(C.get_game_info().id == C.settings:get("hearth_recovery_gameid"), "Disposable game selection mismatch") assert(type(hearth) == "table" and type(hearth.persistence) == "table", "Persistence not wired") assert(type(hearth.schema) == "table" and type(hearth.storage) == "userdata", "Real storage/schema missing") assert(not C.get_modpath("hearth_smoke"), "Legacy smoke mod loaded") assert(C.settings:get("hearth_environment") == "staging", "Not a staging boot") assert(C.settings:get("bind_address") == "127.0.0.1" and C.settings:get("max_users") == "0", "Client isolation missing") assert(C.settings:get_bool("secure.enable_security") == true, "Mod security disabled") no_clients() local expected = read("m0-recovery-expected.json") assert(expected.format == 1 and expected.token == token, "Wrong original evidence") local saved = {} for _, kind in ipairs(kinds) do local raw, before_backup = expected.keys[kind], expected.backups[kind] assert(type(raw) == "string" and type(before_backup) == "string", "Missing pre-migration bytes") local backup_key = "m0_backup:" .. kind local backup = hearth.storage:get_string(backup_key) if raw ~= "" then report.existingKeys = report.existingKeys + 1 local normalized, legacy = hearth.schema.decode(kind, decode(raw)) if legacy then report.legacyKeys = report.legacyKeys + 1 end -- Inspect BEFORE load_world: a lazy probe must not hide missing startup migration. local actual = envelope(kind, hearth.storage:get_string(kind)) assert(equal(actual, normalized.data), "Stored original data changed during migration") local expected_backup = before_backup ~= "" and before_backup or (legacy and raw or "") assert(backup == expected_backup, "Original backup bytes changed") if legacy then local recovered, was_legacy = hearth.schema.decode(kind, decode(backup)) assert(was_legacy and equal(recovered, normalized), "Legacy backup cannot recover original data") end saved[kind] = hearth.persistence.load_world(kind) assert(equal(saved[kind], normalized.data), "Adapter changed original world data") hearth.persistence.save_world(kind, saved[kind]) assert(hearth.storage:get_string(backup_key) == backup, "Save replaced immutable backup") else assert(backup == before_backup, "Missing record created a backup") end end assert(report.existingKeys > 0 and report.legacyKeys > 0, "Not an existing legacy save") assert(saved.spawn, "Original saved spawn required") assert(equal(hearth.persistence.load_world("spawn"), saved.spawn), "Saved spawn changed") local claims, homes = saved.claims or {}, saved.homes or {} assert(type(hearth.claim_allowed) == "function", "Claims protection missing") assert(type(hearth.home_for) == "function", "Home lookup missing") for owner, home in pairs(homes) do local exposed = hearth.home_for(owner) assert(equal(exposed, home), "Persisted home lookup changed") exposed.x = exposed.x + 1 assert(equal(hearth.home_for(owner), home), "Home lookup exposed mutable storage") end report.homes = true local outsider = "__hearth_recovery_outsider" while claims[outsider] do outsider = outsider .. "_" end report.claimCount = 0 for owner, pos in pairs(claims) do report.claimCount = report.claimCount + 1 assert(not hearth.claim_allowed(pos, outsider) and C.is_protected(pos, outsider), "Original claim lost protection") local allowed = true for other, center in pairs(claims) do if other ~= owner and math.abs(pos.x - center.x) <= 12 and math.abs(pos.z - center.z) <= 12 and math.abs(pos.y - center.y) <= 24 then allowed = false end end assert(hearth.claim_allowed(pos, owner) == allowed, "Claim ownership changed") end report.claims = true return saved.spawn end local original_needs = {food = 72, water = 63, warmth = 54, wet = 9, stamina = 81} local saved_needs = {food = 61, water = 52, warmth = 43, wet = 8, stamina = 79} local function fixture_check() failure_label = "checkpoint-fixture-check" local meta = C.get_meta(fixture_pos) -- The deterministic synthetic player only adapts get_meta; its backing store -- is REAL engine node metadata, and the adapter uses REAL engine JSON. local player = {get_meta = function() return meta end} if phase == "seed" then for _, pos in ipairs({fixture_pos, expired_pos, live_pos}) do assert(C.get_node(pos).name == "air", "Isolated fixture space occupied") end C.set_node(fixture_pos, {name = "hearth:wall", param2 = 2}) meta = C.get_meta(fixture_pos) local original_raw = assert(C.write_json(original_needs)) meta:set_string("hearth:needs", original_raw) assert(equal(hearth.persistence.load_player(player), original_needs), "Real-JSON player migration failed") assert(meta:get_string("hearth:m0_backup:needs") == original_raw, "Player legacy bytes not backed up") hearth.persistence.save_player(player, saved_needs) assert(equal(envelope("needs", meta:get_string("hearth:needs")), saved_needs), "Player fixture not saved") assert(meta:get_string("hearth:m0_backup:needs") == original_raw, "Player save overwrote backup") meta:set_string("recovery_token", token) local inv = meta:get_inventory() inv:set_size("main", 4) local stack = ItemStack("hearth:axe") stack:set_wear(1379) stack:get_meta():set_string("recovery", token) inv:set_stack("main", 1, stack) inv:set_stack("main", 2, ItemStack("hearth:berries 3")) local now = os.time() local fixture = {format = 1, token = token, originalNeeds = original_raw, liveDeadline = now + 300, expiredDeadline = now - 60, stack = stack:to_string(), berries = inv:get_stack("main", 2):to_string()} for _, pos in ipairs({expired_pos, live_pos}) do C.set_node(pos, {name = "hearth:fire_lit"}) end C.get_meta(expired_pos):set_string("fuel_until", tostring(fixture.expiredDeadline)) C.get_meta(live_pos):set_string("fuel_until", tostring(fixture.liveDeadline)) hearth.storage:set_string("m0_recovery:marker", token) write("m0-recovery-fixture.json", fixture) else local fixture = read("m0-recovery-fixture.json") assert(fixture.format == 1 and fixture.token == token, "Fixture evidence not restored") assert(hearth.storage:get_string("m0_recovery:marker") == token, "Real mod storage marker lost") local node = C.get_node(fixture_pos) assert(node.name == "hearth:wall" and node.param2 == 2, "Map node did not survive backup/restore") assert(meta:get_string("recovery_token") == token, "Real node metadata lost") assert(equal(hearth.persistence.load_player(player), saved_needs), "Player fixture did not survive restart") assert(meta:get_string("hearth:m0_backup:needs") == fixture.originalNeeds, "Player immutable backup changed on restart") assert(equal(decode(fixture.originalNeeds), original_needs), "Player original fixture changed") local inv = meta:get_inventory() assert(inv:get_size("main") == 4 and inv:get_stack("main", 1):to_string() == fixture.stack and inv:get_stack("main", 2):to_string() == fixture.berries and inv:get_stack("main", 3):is_empty() and inv:get_stack("main", 4):is_empty(), "Real inventory bytes changed") assert(inv:get_stack("main", 1):get_wear() == 1379 and inv:get_stack("main", 1):get_meta():get_string("recovery") == token, "ItemStack metadata lost") -- Forceload activation must execute the game's real run-at-every-load LBM; -- do not invoke its callback manually or replace the engine clock. assert(C.get_node(expired_pos).name == "hearth:fire" and C.get_meta(expired_pos):get_string("fuel_until") == "0", "Expired fire was not reconciled by LBM") assert(fixture.liveDeadline > os.time(), "Fixture live deadline exceeded") assert(C.get_node(live_pos).name == "hearth:fire_lit" and tonumber(C.get_meta(live_pos):get_string("fuel_until")) == fixture.liveDeadline, "Absolute fire deadline extended or lost") end report.fixture = true end local function emerge(minimum, maximum, done, context) local complete = false C.emerge_area(minimum, maximum, function(_, action, remaining) guard(function() if action == C.EMERGE_ERRORED or action == C.EMERGE_CANCELLED then failure_label = "checkpoint-" .. context .. (action == C.EMERGE_ERRORED and "-emerge-errored" or "-emerge-cancelled") fail() return end if remaining == 0 and not complete then complete = true C.after(0, function() guard(done) end) end end) end) end local function await_activation() guard(function() failure_label = "checkpoint-fixture-activation" no_clients() -- Forceload activation follows the engine's active-block management -- interval, not necessarily the emerge callback or the next server step. -- Wait for actual LBM effects; the independent 75s watchdog bounds this. if not C.compare_block_status(fixture_pos, "active") then C.after(0.1, await_activation) return end if phase == "restart" and (C.get_node(expired_pos).name ~= "hearth:fire" or C.get_meta(expired_pos):get_string("fuel_until") ~= "0") then C.after(0.1, await_activation) return end fixture_check() ready = true C.request_shutdown("Private recovery complete", false, 0) end) end C.register_on_prejoinplayer(function() return "Private offline recovery does not accept clients" end) C.register_on_joinplayer(function() fail() end) C.register_on_shutdown(function() if not ready or finished then return end local ok = pcall(function() failure_label = "checkpoint-shutdown-evidence" no_clients() report.noClients = true report.verifiedAssertions = verified_assertions write("m0-recovery-" .. phase .. ".json", report) end) finished = true if ok then C.log("action", "LAST_HEARTH_RECOVERY_PASS " .. phase .. " " .. token) else C.log("error", "LAST_HEARTH_RECOVERY_FAIL " .. failure_label) end end) C.after(0, function() guard(function() local spawn = world_check() local p = vector.round(spawn) emerge(vector.offset(p, -2, -2, -2), vector.offset(p, 2, 2, 2), function() local node = C.get_node_or_nil(p) assert(node and node.name ~= "ignore" and C.registered_nodes[node.name], "Saved spawn map unavailable") assert(equal(hearth.persistence.load_world("spawn"), spawn), "Spawn changed during emergence") report.spawn = true assert(C.forceload_block(fixture_pos, true), "Cannot activate fixture mapblock") emerge(fixture_pos, vector.offset(fixture_pos, 3, 1, 1), function() C.after(0, await_activation) end, "fixture") end, "spawn") end) end) C.after(75, function() if not ready then fail() end end) end