feat(save-editor): move an NPC and let him stay there - #62
Conversation
Moving an NPC in the save DOES take effect on load — verified in game, against the opposite conclusion this repo recorded a week ago. What the save does not control is whether he stays: his daily routine reclaims him within seconds, and a streamed-out NPC is teleported back rather than walked. Writing DailyRoutineClass = DailyRoutine_Empty alongside the position is what makes a move stick; that class is the one of 2777 whose bytecode does nothing at all. The earlier round of tests reached for DailyRoutine_Empty_StayAtSpawn instead, whose Activate_Implementation snaps to GetPreferredLocation().StartingPosition — the level actor, never the save. It undid the very edit it was meant to protect, and the two NPCs it moved both had an active routine task, so nothing in that experiment could have worked. Both dead ends are now written down where the next person will look. private.npc.position gains routineClass, its typed path, the inert class by name (so the app never spells it out and drifts), and any recorded undo. Swapping the routine destroys something the save then holds nowhere: the class an NPC was on is story state (_Collapsed, _WaitYard, _TcWait), not the _Start the naming convention suggests, so it cannot be derived back. placement.rs writes it down beside the save, in the same shape and with the same care as the backup-label map — staged and swapped, published only while the file is still what was read, and an unreadable file blocks a write instead of being overwritten. Beside the save rather than inside it on purpose: the game re-serializes the whole save from live state on the next in-game save, so an in-save marker would be gone exactly when the undo is wanted, while DailyRoutine_Empty — live state by then — would survive. write_save takes placementNotes/clearPlacementNotes. They are parsed BEFORE the write, so a malformed note fails the request instead of the save landing with no undo recorded, and written AFTER it, so a note never describes a move that did not happen. A sidecar failure is reported beside a good save rather than turned into a failed one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Position tab's NPC half was made read-only a week ago on the finding
that the game restores an NPC's placement from the level and discards the
saved pose. That finding was wrong: written on its own, CharacterLocation
really does put him there on load — an NPC written to a freepoint 82,000
units away was standing on it — and his daily routine then walks him back
within seconds. So the editing machinery comes back, together with the
second write that makes a move hold.
The routine is a checkbox of its own, below the fields and not inside the
location picker. The picker is deliberately write-agnostic — no command,
no path, no pending key — which is what lets one dialog serve the hero and
an NPC; and its existing box ("apply the spot's facing") is a property of
the SPOT, while this one is a property of the NPC and has to hold just as
much when coordinates are typed by hand.
It shows the NPC's stored state rather than modifying a pending move, so
it is always visible, ticking it alone freezes him where he stands, and
only a DIFFERENCE from the stored state is ever written. It starts off:
wiping a schedule is a deliberate act, not a side effect of editing a
coordinate. The cost is that a plain move does not stick unless the box is
ticked too, which the subtitle says.
Unticking gives the routine back from the recorded note, and taking a
whole move back restores position and routine together. Both are offered
only while the core still vouches for the note — once the save no longer
holds what that move wrote, restoring it would discard whatever happened
since. The core reports the two verdicts separately, because a moved
position must not block giving the routine back.
The lock is gated on the STORED state, never on the tick just made.
Gating it on the tick greyed the box out for every NPC the moment it was
ticked, claiming there was no way back from a change that had not been
saved — while unticking would simply have dropped the pending edit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f1bacd5edf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Four findings from the automated review, all real. A queued pin was lost on a revisit. The checkbox was reseeded from the save while the pending registry still held the routine swap, and the next coordinate keystroke rebuilt the entry without it — saving the move as position-only, which is the one shape that does not stick. The panel now resumes the box from the QUEUE, which decides it in both directions: the inert class means a pending pin, any other class a pending restore. The undo note was dropped whenever the pending set was rebuilt after a failed or partial save, so a retry could commit the move with nothing recording the routine it replaced. Both rebuild sites carry it now. Re-recording a note whose sub-write did commit is harmless — it is keyed by NPC and identical — while losing it strands an NPC with no way back. With outputPath the bytes land in the export while the note was recorded against the source: the export got no undo and an untouched file got a note for a move it does not contain. The sidecar now follows the file the bytes landed in. A failed sidecar write was reported by the core and read by nobody, so the UI announced success and cleared the drafts while the replaced routine was gone unrecorded. The warning now rides the save message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
A sub-write that committed put the NPC move on disk; a later sub-write failing does not take it back. The placement warning from the committed write was collected and then dropped on that path, so the user saw only the save error while the routine the pin replaced was recorded nowhere. The warning now rides the error message too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0624d4860c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Three findings from the automated review, all real. A move that also turned the NPC — the location picker can apply a spot's heading — recorded only the two locations. Taking the move back then put the position and routine right, left the new facing, and cleared the only record of the old one. The note now carries the rotation on both sides, the freshness check tests it, and the restore writes it back. The freshness check compared the noted f64 against the stored value. A 12-byte Vector is written as three f32, so a note would never equal what reads back: every fresh note on such a save was stale on arrival and the undo was dead before it was offered. The comparison now accepts the f32 round-trip as well as exact equality. Deleting the sidecar released its claim and only then unlinked, so a second editor publishing in that window had its notes deleted — and the ignored unlink error would have hidden it. The delete now goes through the same claim-guarded path the backup-label map already used, extracted so both maps share one implementation rather than two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 83b2ba4e70
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Three findings from the automated review, all real. A DailyRoutineByGlobalId entry without a DailyRoutineClass member still handed back a path, so the editor offered the pin and the save then failed: private.typed.setValue patches an existing property and cannot create a missing one. The path is withheld when the member is absent — "a path came back" is the caller's only signal that a leaf is writable. Importing an external save copies its bytes to a new slot, but the notes are keyed by file name and stayed with the source. The import held DailyRoutine_Empty with no record of what it replaced, which locks the routine control for good. The notes are copied across after the bytes land; the source keeps its own, because both files now describe the same pinned NPCs. A backup captures the save as it is, pinned NPCs included, and so has to capture what those pins replaced. Without that, undoing a pin spent the only copy and restoring the backup later brought DailyRoutine_Empty back with nothing to undo it. Creating a backup now snapshots the notes under the backup's file name, restoring puts that snapshot back wholesale (including "none", so a pre-pin backup stops offering an undo the restored bytes do not contain), and deleting a backup drops its snapshot so the next file to reuse the name does not inherit it. None of the three may fail the operation they ride on: the bytes are already safe by then, so a sidecar failure is reported beside a completed backup, restore or import instead of turning one into an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
carry upserted only the NPCs the source had, so entries left behind by an earlier file of the destination's name survived and offered to undo moves the copied bytes do not contain. A source with no notes did not clear the destination key at all. The copy is wholesale now, including the empty case — the same rule the backup snapshot already followed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6dc3a5ce25
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Restoring a backup installs the notes that describe it, and the core already reported a failure to do so beside the completed restore — but nothing read the field, so the UI announced a clean restore while the restored save could hold a pinned NPC with no record of the routine that pin replaced. The warning now rides the restore message. The same field on deleting a backup and on importing an external save was equally unread, and for the same reason: those two also write notes after their bytes are already safe. Both now report it too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 90402b128c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The backup snapshot lived inside the byte writer and discarded its own error, so a save whose notes could not be captured still reported a fully successful backup. Undoing the live pin later spent the only copy, and restoring that backup brought DailyRoutine_Empty back with nothing to undo it. The snapshot moves out to the three places that back up a SAVE — write, restore and profile assignment — each of which already carries a warning field for exactly this: the bytes are safe by then, so it is reported beside a completed operation, never turned into a failure. The transfer paths also read through the forgiving reader, which turns an unreadable sidecar into "no notes". That is right for a display and wrong here: an import would have reported success while clearing the destination's notes, and a restore would have decided there was nothing to install. Transfers now read strictly and say which file they could not read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5712e726d1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Moving an NPC who was already pinned queued only the pose edit. The note still named his previous position, so the save no longer held what the note said it wrote and the undo went stale the moment that move landed — stranding the original position and routine it recorded. The note is refreshed on such a move, keeping the ORIGINAL half from the first pin, because a restore has to reach the state before he was ever pinned. The backup snapshot returned early for a save with no notes, so a reused backup file name — delete and recreate inside the same timestamp second, or an earlier cleanup that failed — kept the deleted file's notes and would have installed them on restore. The snapshot is wholesale now, the same rule the import copy already followed. Removing the sidecar short-circuits when there was nothing there, because the claim it stages needs the backups folder to exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a95da53ade
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…l needs A rotation-only edit on a pinned NPC skipped the note refresh, so the saved facing no longer matched the one the note named and the undo went stale on the spot. Either half of the pose moving now refreshes it. A second move that changed only the position dropped written_rotation while original_rotation survived from the first pin. The core then stopped checking the facing for staleness while the undo would still restore it, so an in-game turn could be overwritten by taking the move back. A note that restores a facing now always guards it. Deleting a backup cleared the shared placement key even when the same file name still existed in the other supported backup location, losing the surviving copy's undo metadata. Gated on "no backup carries that name any more", exactly as the label cleanup beside it already was. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3c31c09adc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
… pin A write with outputPath copies the bytes wholesale but only applied the notes this request supplied. Another NPC already pinned in the source arrived in the export pinned with no undo, while notes left by an earlier file of the export's name survived. The export now starts from the source's whole note set, with this request's records and clears as the delta on top. The checkbox was offered for an NPC whose routine leaf is writable but whose stored location is absent. Ticking it queued nothing while the box stayed visibly ticked, because a note needs the location it replaced. Offering it now requires a location to record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 3ae794b. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3ae794b95f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…er location The snapshot ran after the guarded replace, so a write that aborted there left a listed, restorable backup of a pinned save with no record of the routine its pins replaced. It now runs the moment the backup file exists, and the write reports what it could not capture. Backup notes were keyed by file name alone, but the same name can exist twice — beside the save and in goresave_backups — and both are listed and restorable. A snapshot of either overwrote the other's undo metadata, and a restore could install the wrong one. The key now distinguishes the two locations, and the surviving-copy check that gates deletion follows it. A failed carry on export no longer suppresses this request's own notes: they describe the move the write just put on disk, so they are attempted regardless and every warning is reported together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ede36f75a7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let placement_note_warning = placement::snapshot_backup(path, ¤t_backup_path) | ||
| .and_then(|()| restore_backup_placement_notes(path, backup_path)) |
There was a problem hiding this comment.
Snapshot restore safety notes when creating the backup
When restoring a pinned save, the safety backup is created before staging and guarded replacement, but its placement notes are not snapshotted until after both replacements commit. If validation, a concurrent file change, or the companion replacement aborts the restore, the safety backup remains listed without its NPC undo metadata; after the live pin is undone, restoring that backup brings back DailyRoutine_Empty with no recoverable original routine. Fresh evidence beyond the earlier write_save finding is that the current restore path still performs this snapshot only after every fallible replacement step.
Useful? React with 👍 / 👎.
| let placement_note_warning = backup_path | ||
| .as_deref() | ||
| .map_or(Ok(()), |backup| { | ||
| placement::snapshot_backup(target_path, Path::new(backup)) | ||
| }) |
There was a problem hiding this comment.
Snapshot profile-assignment backups before guarded writes
When assigning an already-pinned save to another profile, the slot backup is created before the two guarded replacements, while this placement snapshot runs only after both replacements succeed. If either replacement fails—for example because PersistentDataList.sav changes concurrently—the backup remains on disk without its placement-note key; undoing the live pin and later restoring that backup then loses the original routine. Fresh evidence beyond the earlier write_save finding is that the profile-assignment workflow retains the same post-commit snapshot ordering.
Useful? React with 👍 / 👎.

What this is
NPC position editing, back — and this time it holds.
The Position tab's NPC half was made read-only a week ago on the finding that the game restores an NPC's placement from the level and discards the saved pose. That finding was wrong. Written on its own,
CharacterLocationreally is applied on load: an NPC written to a freepoint 82,000 units from where he stood was standing on that freepoint after the load. His daily routine then walks him back within seconds — which is what the earlier tests saw and misread.Making a move stick takes a second write:
DailyRoutine_Emptyis the one class of 2777 in the script cache whose bytecode does nothing at all — constructor only, noActivate_Implementation, noHandleAvatarChanged.How the old conclusion went wrong
Two independent mistakes that confirmed each other:
DailyRoutine_Empty_StayAtSpawn, whoseActivate_ImplementationcallsSetCharacterLocationAndDirection(GetPreferredLocation().StartingPosition, …)— the level actor, never the save. Of 2777 routine classes it picked the one guaranteed to undo the edit.A third confound was ruled out along the way: teleporting an NPC onto the hero's exact coordinates is not rejected for capsule overlap.
Both dead ends are now documented at the constant and the panel, so the next person does not re-derive them.
Verification
Proven in game, not inferred. A/B/C in one save, one load, one variable each:
SC_NOV_Shrat_1356DailyRoutine_EmptyFM_GRD_Guard04_286(has an assignedUsedSpot)DailyRoutine_EmptyOM_STT_Alberto_300The undo
Swapping the routine destroys something the save then holds nowhere: the class an NPC was on is story state (
_Collapsed,_WaitYard,_TcWait), not the_Startthe naming convention suggests. It cannot be derived back, so it is written down.crates/gore-save/src/placement.rskeeps it beside the save, in the same shape and with the same care as the backup-label map — staged and swapped, published only while the file is still what was read, an unreadable file blocking a write instead of being overwritten.Beside rather than inside on purpose: the game re-serializes the whole save from live state on the next in-game save, so an in-save marker would be gone exactly when the undo is wanted, while
DailyRoutine_Empty— live state by then — would survive.write_savetakesplacementNotes/clearPlacementNotes, parsed before the write (a malformed note fails the request rather than letting the save land with no undo) and written after it (a note never describes a move that did not happen). A sidecar failure is reported beside a good save, not turned into a failed one.UI
The routine is a checkbox of its own, below the fields and not inside the location picker: the picker is deliberately write-agnostic, which is what lets one dialog serve the hero and an NPC, and its existing box ("apply the spot's facing") is a property of the SPOT while this one is a property of the NPC.
Tests
385 Rust, 512 Flutter,
flutter analyzeclean. New coverage: the sidecar's round-trip, its refusal to overwrite an unreadable file, and nine widget tests over the checkbox semantics — starts off, ticking alone freezes, ticking does not lock, locked only without a note, and the restore paths.🤖 Generated with Claude Code
Note
Medium Risk
Changes how save files and placement sidecars are written and how partial failures are reported; mistakes could strand NPCs or lose undo metadata, though the flow is heavily tested and notes are designed not to fail the main save.
Overview
NPCs can be moved again from the Characters → Position tab: coordinates are editable (manual entry or the same location picker as the hero), with per-NPC pending keys, validation that blocks Save on bad input, and draft rehydration when switching NPCs.
A move that should stick queues both
CharacterLocation(and optional rotation) and a swap to the core’s inertDailyRoutine_Emptyclass via a separate “disable daily routine” checkbox, plus optional undo when the core still considers the recorded placement restorable.Placement undo is not part of the
.savbytes:PendingSaveEditnow carriesplacementNotes/clearPlacementNotes, aggregated on the firstwrite_savesub-write; partial saves and retries keep those notes. The UI surfacesplacementNoteWarningon successful saves, failed multi-writes, profile assign, restore, and backup delete when the sidecar note fails.Domain/UI updates include struct-based
private.typed.setValueedits, routine/undo metadata fromprivate.npc.position, and isolated invalid-edit keys fornpc.position:vs attributes. The old read-only NPC position test is removed in favor of new widget/notifier coverage; changelog and strings are updated across locales.Reviewed by Cursor Bugbot for commit ede36f7. Bugbot is set up for automated code reviews on this repo. Configure here.