From 88fa7b570ce5562ecc315f0d35900fa169c81808 Mon Sep 17 00:00:00 2001 From: raptorreplica Date: Sat, 10 Oct 2026 23:02:47 +1100 Subject: [PATCH] Prevent stale HD generation reservations across interrupted turns Track reserved, started and legacy-unknown requests separately; retain active request accounting until explicit completion and expose recovery controls. Reject duplicate or unknown starts, preserve ownership checks, and validate the lifecycle with isolated synthetic state. Record independently verified palette fixes and add a supplied-HD timed currency scene fixture covering interpolation and unchanged simulation state. --- RustRaptor/docs/hd-full-review.md | 8 +- RustRaptor/src/desktop.rs | 135 +++++++++++++++++++++++++ RustRaptor/tools/hd_generation_gate.py | 70 +++++++++++-- 3 files changed, 200 insertions(+), 13 deletions(-) diff --git a/RustRaptor/docs/hd-full-review.md b/RustRaptor/docs/hd-full-review.md index 6764c173..0e3bc0f8 100644 --- a/RustRaptor/docs/hd-full-review.md +++ b/RustRaptor/docs/hd-full-review.md @@ -36,7 +36,7 @@ Evidence: `.reference/hd-full-review/snapshot-aa2b823a/` contains the exact mani A subsequent registered snapshot, **63 frames**, has manifest SHA-256 `33ec0a782a852ffef36880432d3e8d472f18a39e9a31d545fe47eb0256bb0dfd`. Fresh actual Rust validation passes all 252 scale outputs without diagnostics. Every prior 46 frame's indexed output is byte-identical to the previous reviewed snapshot; the 16 canonical terrain frames and Super Shield pickup match their independently reviewed isolated outputs. The newly added terrain's native 2× and 3× outputs were also inspected. The picture inventory still has **5,690 unregistered targets** at this snapshot. Evidence: `.reference/hd-full-review/snapshot-33ec0a78/review-comparison.json`. -**Palette animation acceptance is temporarily held** while a discovered generic HD quantization issue is corrected: static equipment highlights can select native cycling/fire/flash indices 240–255 despite their original source having none. The PNG source and shape can be valid while the runtime asset flashes unintentionally. The coordinator reported this in Micro Missile/Missile Pods/Mega Bomb candidates and requested an engine correction. Recheck the full registered pack after that correction before treating the previous geometry approvals as complete runtime palette approval. +**Palette animation defect found and corrected:** static equipment highlights can select native cycling/fire/flash indices 240–255 despite their original source having none. The PNG source and shape can be valid while the runtime asset flashes unintentionally. The coordinator reported this in Micro Missile/Missile Pods/Mega Bomb candidates and requested an engine correction. Independent pre-fix review found 24 of 63 registered pictures affected across 86 scale outputs. Source-bound palette classes now constrain ordinary art to indices 0–239, water/fire to their own cycling group, and flash masks to exact original labels. Fresh independent post-fix galleries pass **all 63 registered pictures and ten additional equipment frames at scales 1–4 (292 outputs)** with no newly introduced dynamic index. All 24 changed registered frames and all ten equipment frames were visually re-inspected without a shape or readability regression. Evidence: `.reference/hd-full-review/snapshot-63-palette-audit-fixed/` and `equipment-palette-fixed/`. ## Isolated candidate reviews @@ -66,16 +66,16 @@ Runtime proof SHA-256: `c00e11d26e2a49a6b0f2e156581a7a7b1fc217f922a34e6b70ed7dbd The former original beam/effect routing gaps have dedicated native composition paths. Beam shafts, muzzle/power/impact companions and secondary smoke/sparks/glows preserve original frame IDs and original anchors. Original shadows deliberately retain the original logical masks and collision dimensions. -Code review of the newly extended HUD, timed currency pickup and light-mask routes confirms they request HD frames at the presentation scale while retaining source logical placement. Light masks call indexed `Canvas::shade` instead of painting RGBA colors. Generic HUD assets keep nearest-neighbor source fallback; bitmap font text remains sharply scaled. Focused runtime fixtures now pass at **1× and 4×**: generic HUD and equipped-weapon icons use supplied HD pixels at the original logical anchor; a sparse red RGBA shield replacement uses its own alpha holes and transforms background index **37→44** through the light table rather than painting its red foreground. World/pilot state remains unchanged. The broader 13 original-rendering checks also pass. Supplied-art currency pickup reachability still needs a dedicated runtime assertion or capture; no currency replacement is registered in the reviewed snapshot. +Code review of the newly extended HUD, timed currency pickup and light-mask routes confirms they request HD frames at the presentation scale while retaining source logical placement. Light masks call indexed `Canvas::shade` instead of painting RGBA colors. Generic HUD assets keep nearest-neighbor source fallback; bitmap font text remains sharply scaled. Focused runtime fixtures now pass at **1× and 4×**: generic HUD and equipped-weapon icons use supplied HD pixels at the original logical anchor; a sparse red RGBA shield replacement uses its own alpha holes and transforms background index **37→44** through the light table rather than painting its red foreground. World/pilot state remains unchanged. The broader 13 original-rendering checks also pass. A dedicated full `Host::scene` currency fixture now passes at 1×/4× and interpolation α=0/0.5: synthetic native `N$_PIC` stays green in SD while supplied red/blue HD halves appear at the interpolated source anchor, with every pickup pixel and its surrounding black border checked. At 4× the half-step anchor is `(246,410)`. World digest, RNG, pickup countdown/previous-art-position/glow-frame, pilot and clock state remain unchanged; no HD diagnostic appears. This proves route behavior separately from artistic acceptance; no currency remaster is approved by that synthetic test. Canonical terrain candidate review and actual terrain composition checks are ongoing. Fresh isolated Rust review decoded all 32 tiles in the first two groups at scales 1–4 without diagnostics; all eight paged source/1×/4× contact sheets were inspected. The first group (`10360–1036f`) keeps the broad tree/grass/dirt placements. The second group is held for the concrete source drift below. Every candidate tile requires independent source geometry review plus neighboring placements, mixed HD/original joins, destroyed states and scroll phases. Opaque alpha-mask overlap alone is trivial and is not an artistic quality gate. Rendering test success adds no artwork coverage. ## Open findings - Full art generation and registration are unfinished; preserve the per-ID checklist through every cohort. -- Correct generic HD quantization to preserve original palette animation classes, then rerun every registered frame at scales 1–4. Hold new equipment registration pending that fix; no regeneration is required for a quantizer-only defect. +- Generic HD palette quantization is corrected and independently passes all current supplied frames at scales 1–4. Preserve this source-class gate for every future family. - Firing-player frames require separate source-contour review, especially straight frame `2004f`; the dark/light banking review does not approve that sequence. -- Generic HUD and lighting-mask routes now pass source-anchored indexed runtime fixtures at 1×/4×. Confirm the timed currency pickup route with supplied art and a runtime assertion or capture. +- Generic HUD, lighting-mask and timed-currency routes now pass supplied-art runtime fixtures at 1×/4×, preserving indexed shading and original/interpolated anchors. Review their actual generated artwork separately when candidates arrive. - **Hold terrain `2010d–2011c`: picture `20111` invents a tree canopy.** Its original palette contains olive grass and brown dirt only; the remaster adds a bright green canopy at the upper-left covering about 15% of logical pixels. The source hash and opaque alpha-mask overlap pass, demonstrating why those checks do not establish source geometry. Regenerate that picture with the original grass/dirt arrangement before approving the family. Coordinator, artist and terrain engineer were notified. Proof: `.reference/hd-full-review/terrain-2010d-2011c/runtime-contact-sheet-002.png` and `20111-original-enlarged.png`. - First canonical terrain group `10360–1036f` is independently approved; complete the corrected second group contextual review before registration. diff --git a/RustRaptor/src/desktop.rs b/RustRaptor/src/desktop.rs index 7a8331e3..f269aa44 100644 --- a/RustRaptor/src/desktop.rs +++ b/RustRaptor/src/desktop.rs @@ -4810,6 +4810,141 @@ mod tests { assert_eq!(host.hd.coverage(), 1); assert_eq!(serde_json::to_value(&host.pilot).unwrap(), before); } + #[test] + fn timed_currency_pickup_uses_hd_pixels_at_interpolated_native_position_without_mutation() { + use crate::simulation::{LegacyPickup, Pickup}; + use sha2::{Digest, Sha256}; + + let (_dir, mut host) = ui_fixture(); + install_player_fixture(&mut host); + let root = host.catalog.root.clone(); + let source = [1_i32, 0, 0, 4, 4] + .into_iter() + .flat_map(i32::to_le_bytes) + .chain([5; 16]) + .collect::>(); + std::fs::write(root.join("N$_PIC"), &source).unwrap(); + let index = std::fs::read_to_string(root.join("INDEX")).unwrap(); + std::fs::write( + root.join("INDEX"), + format!("{index}1000f {} N$_PIC\n", source.len()), + ) + .unwrap(); + host.catalog = Catalog::open(&root).unwrap(); + let hd_root = root.join("hd"); + std::fs::create_dir(&hd_root).unwrap(); + image::RgbaImage::from_fn(16, 16, |x, _| { + image::Rgba(if x < 8 { + [255, 0, 0, 255] + } else { + [0, 0, 255, 255] + }) + }) + .save(hd_root.join("currency.png")) + .unwrap(); + let manifest = serde_json::json!({"format_version":1,"name":"Timed currency fixture","sequences":[{ + "name":"Currency","base_asset":0x1000f,"frame_count":1,"frames":[{ + "frame":0,"file":"currency.png","logical_width":4,"logical_height":4, + "origin":[0,0],"source_sha256":format!("{:x}",Sha256::digest(&source)) + }]}]}); + std::fs::write( + hd_root.join("hd.json"), + serde_json::to_vec(&manifest).unwrap(), + ) + .unwrap(); + host.palette[5] = [0, 255, 0]; + host.palette[8] = [255, 0, 0]; + host.palette[9] = [0, 0, 255]; + // The full scene also renders numeric HUD pictures; keep those separate + // from the currency asset used by both the HUD and this timed pickup. + for asset in 0x10005..=0x1000e { + host.raw.insert(asset, Sprite::blank(1, 1)); + } + let mut world = + World::new(&host.pilot.mission, &host.pilot, &host.active.registry).unwrap(); + world.draw_player = false; + world.player.selected = None; + world.tick = 17; + world.rng.seed = 0x1234567; + world.pickups.push(Pickup { + entity: 300, + item: "base:energy".into(), + x: 63, + y: 105, + legacy: Some(LegacyPickup { + x: 71, + y: 113, + position: 3, + frame: 1, + art_frame: 0, + countdown: Some(13), + glow_frame: 1, + previous_art_position: Some([60, 100]), + }), + }); + let before = world.digest(); + let rng_before = world.rng; + let pilot_before = serde_json::to_value(&host.pilot).unwrap(); + host.world = Some(world); + for scale in [1, 4] { + for halfway in [false, true] { + host.clock = Clock::default(); + assert_eq!(host.clock.advance(if halfway { 21_428_571 } else { 0 }), 0); + let alpha_before = host.clock.alpha(); + assert_eq!(alpha_before, if halfway { 0.5 } else { 0.0 }); + let [left, top] = match (scale, halfway) { + (1, false) => [60, 100], + (1, true) => [62, 103], + (4, false) => [240, 400], + (4, true) => [246, 410], + _ => unreachable!(), + }; + for hd in [false, true] { + host.config.hd_mode = hd; + let mut canvas = Canvas::new(scale); + host.scene(&mut canvas).unwrap(); + // Inspect the pickup's complete footprint and one-pixel + // border, far from the HUD's separate dollar glyph. This + // fails if the timed-pickup branch falls back to source art, + // uses collision-center coordinates or rounds before scaling. + for y in top - 1..=top + 4 * scale { + for x in left - 1..=left + 4 * scale { + let expected = if (left..left + 4 * scale).contains(&x) + && (top..top + 4 * scale).contains(&y) + { + if !hd { + 5 + } else if x < left + 2 * scale { + 8 + } else { + 9 + } + } else { + 0 + }; + assert_eq!( + canvas.image.pixels[y * canvas.image.width + x], + expected, + "currency at ({x},{y}), scale={scale}, halfway={halfway}, hd={hd}" + ); + } + } + let after = host.world.as_ref().unwrap(); + assert_eq!(after.digest(), before); + assert_eq!(after.rng, rng_before); + let pickup = after.pickups[0].legacy.as_ref().unwrap(); + // These presentation fields are intentionally not serialized + // by World::digest, so verify them separately as well. + assert_eq!(pickup.previous_art_position, Some([60, 100])); + assert_eq!(pickup.glow_frame, 1); + assert_eq!(host.clock.alpha(), alpha_before); + assert_eq!(serde_json::to_value(&host.pilot).unwrap(), pilot_before); + } + } + } + assert_eq!(host.hd.validated_count(), 1); + assert!(host.hd.diagnostics.is_empty()); + } fn game_input_fixture() -> (tempfile::TempDir, Host) { let (dir, mut host) = ui_fixture(); let pack = serde_json::from_value(serde_json::json!({ diff --git a/RustRaptor/tools/hd_generation_gate.py b/RustRaptor/tools/hd_generation_gate.py index 412bd448..05df06f5 100644 --- a/RustRaptor/tools/hd_generation_gate.py +++ b/RustRaptor/tools/hd_generation_gate.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Coordinate four advisory built-in imagegen submissions across HD workers. -No API call is started or terminated by this tool. Leases last thirty minutes, -may be renewed while a tool yields, and are released with the actual outcome. +No API call is started or terminated by this tool. Unstarted reservations last +thirty minutes. Started and legacy-unknown requests require an explicit finish. Workers without a lease should prepare/review other jobs, then try again. """ import argparse @@ -56,10 +56,13 @@ def status(directory, now=None): raise ValueError("unsupported gate state; do not erase active reservations") leases = [{"lease_id": identity, "owner": lease["owner"], "job_id": lease["job_id"], + "phase": lease.get("phase", "legacy_unknown"), "age_seconds": max(0, int(now - lease["created_epoch"])), "expires_at": utc(lease["expires_epoch"]), "renewal_needed": now >= lease["expires_epoch"], - "stale_reservation": now > lease["expires_epoch"] + EXPIRY_GRACE_SECONDS} + "stale_reservation": lease.get("phase") == "reserved" and now > lease["expires_epoch"] + EXPIRY_GRACE_SECONDS, + "owner_status_needed": lease.get("phase", "legacy_unknown") == "legacy_unknown" + or (lease.get("phase") == "started" and now - lease["created_epoch"] >= LEASE_SECONDS)} for identity, lease in state["leases"].items()] recent = [] log = directory / "events.jsonl" @@ -74,6 +77,8 @@ def status(directory, now=None): acquired = collections.Counter(event["owner"] for event in recent if event["event"] == "acquired") return {"action": "status", "timestamp_utc": utc(now), "capacity": CAPACITY, + "admissions_paused": state.get("admissions_paused", False), + "pause_reason": state.get("pause_reason"), "active_reservations": len(leases), "leases": leases, "cooldowns": [{"owner": owner, "remaining_seconds": int(row["cooldown_until"] - now)} for owner, row in state["owners"].items() @@ -97,14 +102,23 @@ def operate(directory, action, owner, job=None, lease_id=None, outcome=None, err raise ValueError("unsupported gate state; do not erase active reservations") events = [] for identity, lease in list(state["leases"].items()): - if now > lease["expires_epoch"] + EXPIRY_GRACE_SECONDS: + if (lease.get("phase") == "reserved" and identity != lease_id + and now > lease["expires_epoch"] + EXPIRY_GRACE_SECONDS): del state["leases"][identity] events.append({"event": "expired_stale_reservation", "lease_id": identity, "owner": lease["owner"], "job_id": lease["job_id"], "previous_expiry": utc(lease["expires_epoch"]), "note": "Reservation expiry only; no running imagegen call was interrupted."}) response = {"action": action, "owner": owner, "capacity": CAPACITY} - if action == "acquire": + if action in ["pause", "resume"]: + state["admissions_paused"] = action == "pause" + state["pause_reason"] = job if action == "pause" else None + response.update(changed=True, admissions_paused=state["admissions_paused"]) + events.append({"event": action, "owner": owner, "reason": state["pause_reason"], + "note": "Admission policy only; existing tool requests are not interrupted."}) + elif action == "acquire": if not job or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", job): raise ValueError("valid job ID required") - if any(lease["owner"] == owner for lease in state["leases"].values()): + if state.get("admissions_paused"): + response.update(acquired=False, reason="admissions_paused", pause_reason=state.get("pause_reason")) + elif any(lease["owner"] == owner for lease in state["leases"].values()): response.update(acquired=False, reason="owner_already_has_inflight_reservation") elif any(lease["job_id"] == job for lease in state["leases"].values()): response.update(acquired=False, reason="job_already_reserved") @@ -121,10 +135,35 @@ def operate(directory, action, owner, job=None, lease_id=None, outcome=None, err response.update(acquired=False, reason="all_four_submission_slots_reserved") else: identity = str(uuid.uuid4()) - lease = {"owner": owner, "job_id": job, "created_epoch": now, "expires_epoch": now + LEASE_SECONDS} + lease = {"owner": owner, "job_id": job, "created_epoch": now, "expires_epoch": now + LEASE_SECONDS, "phase": "reserved"} state["leases"][identity] = lease response.update(acquired=True, lease_id=identity, expires_at=utc(lease["expires_epoch"]), renewal_command=f"renew --owner {owner} --lease {identity}") events.append({"event": "acquired", "lease_id": identity, "owner": owner, "job_id": job, "expires_at": utc(lease["expires_epoch"])}) + elif action == "restore": + if lease_id in state["leases"]: + if state["leases"][lease_id]["owner"] != owner: + raise ValueError("reservation belongs to another worker") + response.update(changed=False, reason="reservation_already_present") + else: + history = directory / "events.jsonl" + source = None + for line in history.read_text().splitlines() if history.is_file() else []: + event = json.loads(line) + if event.get("lease_id") == lease_id: + if event.get("event") == "acquired": + source = event + elif event.get("event") in ["report", "release"]: + source = None + if not source or source["owner"] != owner: + raise ValueError("restore requires an unfinished recorded acquisition belonging to this owner") + created = datetime.datetime.fromisoformat(source["timestamp_utc"]).timestamp() + state["leases"][lease_id] = {"owner": owner, "job_id": source["job_id"], + "created_epoch": created, "expires_epoch": now + LEASE_SECONDS, + "phase": "legacy_unknown"} + response.update(changed=True, reason="unverified_expired_request_restored") + events.append({"event": "restored_unknown_request", "lease_id": lease_id, + "owner": owner, "job_id": source["job_id"], + "note": "Retain active-call accounting until the owner confirms completion. No tool was started or interrupted."}) else: lease = state["leases"].get(lease_id) if lease is None: @@ -135,6 +174,19 @@ def operate(directory, action, owner, job=None, lease_id=None, outcome=None, err lease["expires_epoch"] = now + LEASE_SECONDS response.update(changed=True, expires_at=utc(lease["expires_epoch"])) events.append({"event": "renewed", "lease_id": lease_id, "owner": owner, "job_id": lease["job_id"], "expires_at": utc(lease["expires_epoch"])}) + elif action == "start": + if lease.get("phase") == "started": + response.update(changed=False, reason="request_already_started") + elif lease.get("phase") != "reserved": + response.update(changed=False, reason="legacy_request_requires_recovery_or_finish") + elif state.get("admissions_paused"): + response.update(changed=False, reason="admissions_paused") + else: + lease.update(phase="started", started_epoch=now, expires_epoch=now + LEASE_SECONDS) + response.update(changed=True, started_at=utc(now)) + events.append({"event": "started", "lease_id": lease_id, "owner": owner, + "job_id": lease["job_id"], + "note": "Owner is submitting the built-in imagegen request; this slot requires an explicit report or release."}) else: del state["leases"][lease_id] event = {"event": action, "lease_id": lease_id, "owner": owner, "job_id": lease["job_id"]} @@ -169,7 +221,7 @@ def operate(directory, action, owner, job=None, lease_id=None, outcome=None, err def main(): project = Path(__file__).resolve().parents[1] parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("action", choices=["acquire", "renew", "release", "report", "status"]) + parser.add_argument("action", choices=["acquire", "start", "renew", "release", "report", "restore", "pause", "resume", "status"]) parser.add_argument("--owner") parser.add_argument("--job") parser.add_argument("--lease") @@ -182,7 +234,7 @@ def main(): return if not args.owner: parser.error("owner required for lease changes") - if args.action != "acquire" and not args.lease: + if args.action not in ["acquire", "pause", "resume"] and not args.lease: parser.error("lease ID required") if args.action == "report" and not args.outcome: parser.error("report needs an actual outcome")