diff --git a/doc/e-series-loader-lifecycle.md b/doc/e-series-loader-lifecycle.md new file mode 100644 index 000000000..09c862f55 --- /dev/null +++ b/doc/e-series-loader-lifecycle.md @@ -0,0 +1,627 @@ +# E-series identity, save, and runtime lifecycle architecture + +**Status:** Canonical current specification +**Applies to:** The completed L-series resource loader and the final corrected E-series save/runtime implementation +**Historical sources:** `Reone_E-Series_Save_Load_Design_Baseline_UPDATED.md` and `reone_l_series_loader_design_v1.0.md` in the project design archive + +This document is the authoritative current E-series architecture specification. The historical E baseline remains the evidence and roadmap record, but its prospective stage descriptions are superseded by this document where they describe current behavior. The L-series design remains authoritative for Odyssey resource selection, lookup buckets, mount planning, and source ownership; this document summarizes only the L contracts needed to understand E. + +The architecture targets K1/K2 observable behavior and content compatibility. It does not reproduce Odyssey filesystem staging, unsafe pointer lifetimes, or destructive failure behavior merely for implementation fidelity. + +## 1. The eight engine laws + +1. Resources have explicit owners; source lifetime is independent of lookup priority. +2. Every serialized object reference has an explicit identity domain. +3. Logical roster identity is a PartyTable slot, explicitly bound to a runtime representation. +4. A creature may survive an Area, but its Area-runtime residency may not. +5. Runtime identity ends at semantic destruction, even if C++ storage remains. +6. A fallible individual object or owned graph becomes gameplay-live only after its own construction, identity, and ownership are coherent. +7. Enough destination resources and structure are prepared to reject avoidable failure before sacrificing a good world. +8. Authored gameplay begins only after the irreversible publication boundary; it is retired coherently on failure, not transactionally rolled back. + +These laws are orthogonal. A saved ID is not a runtime ID, a roster slot is not either ID, an Area attachment is not object existence, and a `shared_ptr` is not gameplay liveness. + +## 2. Architectural phases + +The implementation preserves the original E macro model while refining its commit boundary: + +```text +durable save or current working state + | + v +resource and structural preparation + | + v +source exit and snapshot preparation, when transitioning + | + v +irreversible resource/session publication + | + v +runtime and Party reconstruction + | + v +saved-reference and runtime-state binding/publication + | + v +authored destination entry behavior + | + v +playable-session publication +``` + +These are semantic phases, not a requirement for separate threads or a literal state-machine class. The current loader is synchronous and main-thread-owned. + +There are three distinct commit scopes: + +| Scope | What is staged | Commit meaning | +|---|---|---| +| Save working state | Indexed `SaveWorkingState` and exact slot metadata | Replace the committed save session | +| Module resources | L source index, mount plan, and decoded core structural records | Replace `ActiveModule` and `ActiveModuleState` sources | +| Runtime object graph | One object, an Area-owned construction graph, or an owned child graph | Publish exact live objects and retire obsolete graph members | + +There is deliberately no private duplicate of the complete Game, Party, gameplay registry, or scene. C5 is a bounded structural-preparation boundary, not a database transaction over a gameplay world. + +## 3. L resource ownership and lookup + +E composes with L; it does not own a second resource architecture. + +### 3.1 Lookup is independent of lifetime + +Raw lookup uses the L five-bucket Odyssey order: + +```text +LooseDirectory +> EncapsulatedClass1 +> ResourceImage +> EncapsulatedClass2 +> KeyBif +``` + +Newer registration wins only within a bucket. Primary module selection, mount phase, lookup priority, and source lifetime remain distinct concepts. A winning raw UTC/GIT/ARE does not by itself restore a runtime object. + +### 3.2 Resource owners + +| Owner/state | Purpose | Lifetime | +|---|---|---| +| `Global` | Installation KEY/BIF, override, patch, and other process/game sources | Resource-system shutdown or reinitialization | +| Save session / `SaveSlot` | Exact `SaveSlotDescriptor`, loose metadata view, and indexed `SaveWorkingState` | Save replacement, new game, or session abandonment | +| `ActiveModule` | Module support/static sources such as adjuncts and module archives | Every module transition, including failed committed loads | +| `ActiveModuleState` | Selected saved/current module state | Every module transition and save replacement | +| `TemporaryDiscovery` | Candidate sources mounted only while preparing core records | Preparation completion or failure | + +The durable outer `SAVEGAME.sav` is represented by `SaveSessionState`/`SaveWorkingState`; it is not made the global live resource universe. Saved module candidates are handed to the normal L selection and mount policy. + +### 3.3 Prepared module resources + +`PreparedModuleLoad` owns the selected source index and L mount plan and carries decoded IFO, ARE, and GIT records. Preparation temporarily mounts the candidate plan with `TemporaryDiscovery` ownership, excludes the outgoing `ActiveModule` and `ActiveModuleState` sources from candidate lookup, validates IFO/ARE/GIT and the entry LYT, then rolls the temporary mounts back. Production code can obtain a structurally validated value only through `ResourceDirector`; decoded-record construction used by tests is isolated in a test fixture factory. + +`commitModuleLoad` clears the two old module owners and mounts the already selected plan with its real owners and buckets. An external resource changing between preparation and commit is a post-commit failure; prepared source bytes are not frozen into a second resource store. + +## 4. Serialized identity + +An ObjectId-shaped field is meaningful only with a `SerializedIdentityContext`. + +| Domain | Authority | Reference interpretation | Runtime publication | +|---|---|---|---| +| `ModuleGraph` | One authoritative saved module-instance graph | IDs refer within that graph, including structural Module slot `0` | Saved ID is registered to the independently allocated live runtime object | +| `DetachedRecord` | One owner-/record-local serialized record, such as `pc.utc`, `AVAILNPCn`, `AVAILPUPn`, or party inventory | Local IDs and reference-shaped values never silently bind through the active module graph | Materialization policy may publish supported detached state; it does not invent a module identity | +| `Template` | No authoritative runtime-instance namespace | ObjectId-shaped template data is not a saved module reference | A fresh runtime object receives an independent runtime ID | + +A disk save session and a saved module world are independent facts. `GIT.UseTemplates` is the world-representation discriminator used by the loader: + +- `UseTemplates == 0` identifies an authoritative persisted module graph and therefore a `ModuleGraph` context; +- a template GIT identifies a fresh installed world even when the surrounding operation restores a disk save session. + +The latter is the normal retail transition-autosave topology when the save contains save-wide state but no archived current-module graph. Restoring the session never promotes template-local numbers into saved module identities. + +### 4.1 Saved graph rules + +- Module saved IDs are graph-local, not process-global. +- Saved and runtime IDs may differ arbitrarily. +- Numeric equality across domains proves nothing. +- The saved graph owns `saved ID -> live object` and `live object -> canonical saved ID` translation. +- Structural Module slot `0` is a graph reference target, not an ordinary runtime allocation rule. Its mapping is staged with publication of the runtime Module incarnation and retires with that saved graph. +- Saved Area identity participates in the same staged graph publication. Template Areas and GIT objects receive no `ModuleGraph` identity. +- Inbound references are parsed first, bound only after the graph is materialized, and published only after required bindings are complete. +- Export translates live references to the destination graph's canonical saved IDs exactly once. +- The saved-graph generation scopes parsed/bound references; retirement increments the generation and drops mappings and aliases. +- Missing, deleted, or out-of-graph mandatory references fail closed. Explicitly preserved unsupported payloads remain shadows and are not treated as executable state. + +Known reference-bearing values—including VM objects, Talent items, Effect creator/object parameters, actions, events, delayed script situations, perception records, and saved object fields—use the same context-aware conversion and binding rules. Live Effects in saved continuations are converted to a save-facing value before reference normalization. + +Detached records may contain real effects/actions. Their parse context remains detached, so coincident module numbers cannot capture them. The Party materialization policy decides which supported detached runtime state becomes live. + +### 4.2 World clock and timed state + +Reone's canonical runtime clock is absolute simulation milliseconds. Retail saved module state decomposes it into `Mod_PauseDay` and `Mod_PauseTime`; `Mod_MinPerHour` defines the resulting world-day length and does not accelerate elapsed simulation time. Snapshot output writes the retail pair, normalizes `Mod_PauseTime` below one world day, and removes the erroneous historical Reone fields `Mod_CalendarDay` and `Mod_TimeOfDay` from the merged shadow. + +Reading retains a narrow backward fallback to that historical Reone pair only when neither retail pause field is present. Template-world transition autosaves instead seed the clock from `AUTOSAVEPARAMS.TIME_PAUSEDAY` and `TIME_PAUSETIME`. Saved events, delayed commands, and effect expiry use the reconstructed absolute clock, so their original due schedule survives restoration or supported ordinary-travel continuation. + +## 5. Logical Party and roster identity + +The canonical companion identity is: + +```text +(RosterKind, PartyTable slot) +``` + +`RosterKind::Npc` and `RosterKind::Puppet` are separate namespaces. K1 has nine NPC slots and no puppet namespace; K2 has twelve NPC slots and three puppet slots. + +Party owns these distinct concepts: + +- logical availability; +- selectability; +- detached persistent `AVAILNPCn`/`AVAILPUPn` representation; +- an optional transient runtime-creature binding; +- active membership and leader ordering; +- controlled-NPC state; +- canonical PC storage and current player representation; +- K2 puppet assignment. + +The runtime binding is explicit and replaceable. Party operations bind, clear, lazily spawn, kill, add, remove, or rebuild it. Adding an active member establishes the corresponding binding. Semantic destruction clears the exact matching binding but does not silently change logical availability. + +Availability, binding, membership, and runtime existence are separate authorities: + +- changing availability changes logical Party eligibility only; +- clearing or replacing a binding changes the slot's current representation; +- adding or removing active membership changes team composition; +- explicit semantic destruction ends a runtime incarnation and clears an exact matching binding. + +Party does not own every creature merely because it is bound to a slot. In particular, removing availability neither destroys an independently Area/GIT-owned creature nor implicitly finalizes a detached materialization. Destruction requires an explicit kill/finalization operation. + +Tag is an authored lookup/association value used by explicit retail-compatible routines where their contract calls for it. It is not generic roster identity. Therefore all of these may legally coexist: + +- an available NPC record tagged `remote`; +- an available K2 puppet record tagged `remote`; +- an ordinary GIT creature tagged `remote`. + +GIT state is a module graph; a detached roster snapshot is a save-wide Party record. Neither representation is opportunistically merged into the other. An explicit Party operation may bind a module creature to a slot, after which roster serialization may snapshot that bound creature into the detached record. + +Pointer continuity and runtime ObjectId continuity are not logical continuity. + +The canonical PC remains distinct from the currently controlled creature. A controlled companion retains its saved authored Tag; `player` is synthesized only when the source player representation has no meaningful authored Tag. This preserves script lookup behavior without making Tag a roster identity. + +## 6. Area residency + +Area residency is distinct from both logical Party identity and runtime-object existence. + +### 6.1 Full Area departure + +Full departure ends the current Area lifetime while the Area, Rooms, Triggers, and Pathfinder are still alive. For retained session creatures it retires: + +- Area collections and scene attachment; +- Room membership and raw Room pointers; +- Trigger tenancy; +- Pathfinder-owned path handles and movement derivation; +- blocking-door/navigation bindings; +- combat targets and transient combat state tied to the outgoing Area; +- perception seen/heard bindings to outgoing objects; +- live action and delayed-execution objects after the outgoing snapshot has captured any supported Party continuation state; +- Area-bound presentation/reference components of effects. + +Durable stats, HP, locals, inventory/equipment, logical roster state, and persistent mechanical effect semantics survive. Effect-derived state is preserved while outgoing Area/presentation bindings retire. + +The required source order is: + +```text +OnExit -> snapshot durable state -> full Area retirement -> Module destruction +``` + +For ordinary module travel, supported Party actions and timed commands continue through serialization and reconstruction rather than by retaining outgoing live action objects. Reone captures their save-facing forms while the source graph is authoritative, binds reference-bearing values to exact runtime incarnations, retires the live execution objects with the source Area, and republishes the supported continuation after destination publication. Outgoing-Area-only targets fail closed and cannot capture a destination object through numeric equality. Timed commands retain their original absolute world-time schedule and are not appended twice on repeated travel. + +Full disk restoration follows the independently recovered retail `CreateParty` policy: detached NPC/PUP action queues are cleared when those representations are respawned, while saved module/player runtime state follows the saved-world restoration rules. Same-Area control transfer crosses no serialization or reconstruction boundary. + +### 6.2 Same-Area reposition or control transfer + +`SwitchPlayerCharacter`, Party repositioning, and selection/control transfer do not end the Area lifetime. They preserve action queues, delayed commands, effects, saved-graph generation, combat/perception state unless a specific gameplay rule changes it, and logical roster bindings. They adjust only control, formation, transform, Room calculation, and scene residency needed by the same Area. + +The separate APIs make module-boundary retirement unavailable as a convenient reposition helper. + +## 7. Runtime semantic liveness + +Every gameplay Object is in one of four states: + +| State | Meaning | +|---|---| +| `Constructing` | Storage exists but is not yet a published gameplay object | +| `Live` | This exact runtime incarnation is published and gameplay-addressable | +| `Retired` | Gameplay existence ended; storage may remain | +| `Presentation` | Non-authoritative display object outside the gameplay registry | + +A live object has one runtime ObjectId and one runtime incarnation. The registry is the authoritative publication surface, but non-owning references must also prove that they still address the same live incarnation. + +`RuntimeObjectRef` therefore stores a weak storage reference plus the bound incarnation. Resolution succeeds only if storage remains, the Object is `Live`, and the incarnation still matches. It never resolves by numeric ID. + +Within one runtime-session generation, every numeric runtime ObjectId that has named a published incarnation remains reserved after that incarnation retires. Automatic allocation skips published IDs and explicit same-session reuse is rejected. Full runtime-session retirement clears this published-ID history together with the registry and allocator. This is allocator policy within the runtime-incarnation concept, not another identity domain; `RuntimeObjectRef` still proves exact storage and incarnation rather than relying on numeric nonreuse. + +NWScript's object ABI remains numeric. Immediate script arguments and getters may expose the runtime ID of the currently resolved live object. Long-lived internal gameplay references must either retain exact incarnation semantics or be protected by a narrower lifetime plus the session nonreuse rule; a raw numeric value alone never becomes persistence or logical identity. + +This runtime-incarnation domain is separate from the saved-graph generation and from the runtime-session generation. Saved identity does not own runtime storage, and runtime liveness does not confer persistence. + +### 7.1 Semantic destruction + +Semantic destruction performs these logical steps: + +1. retire children before their owner where an ownership graph exists; +2. mark the exact incarnation `Retired`; +3. clear exact Party runtime bindings and saved-ID mappings/aliases; +4. remove the exact pointer-guarded registry entry; +5. release owner edges as appropriate; +6. let remaining presentation or diagnostic holders release C++ storage later. + +Registry removal cannot unregister a newer object that happens to use the same number. A retained strong pointer cannot restore gameplay liveness. + +## 8. Runtime-object ownership and publication + +Owning edges and non-owning gameplay references are different types of relationship. + +- Creature inventory, equipment, placeable/store contents, and Party inventory form owned runtime-object graphs. +- Every live Item has one stable authoritative disposition: Area/world residency, inventory/container ownership, one Creature equipment slot, another supported owned graph, presentation-only clone, or semantic retirement. `Item::owner` represents nested ownership, not Area residency. +- Normal unequip explicitly moves `equipment -> receiver inventory`; cross-owner transfer explicitly detaches the previous equipment/inventory edge before the destination accepts the Item. +- Moving a world Item into an owned graph first proves exact active-Area residency, then releases its Area indexes, Room/Trigger tenancy and scene attachment before installing the nested owner edge. `owner == 0` alone never makes an Item claimable. +- Equipment replacement prevalidates equipability and the displaced receiver, then commits `new Item -> slot` and `old Item -> receiver`. Removing an equipment edge clears the previous owner relation. +- KOTOR shared-inventory selection belongs to action/UI/gameplay policy. Generic `Creature` equipment ownership does not consult Party policy. +- A stack merge finalizes the consumed object immediately. +- Nested children are retired before their owner. +- Presentation clones never take ownership of authoritative gameplay Items. + +Fallible GFF/blueprint construction uses object-graph staging. The exact implementation sequence is: + +```text +allocate Constructing storage and stage its runtime incarnation/ID + -> stage authoritative saved identity, when the context owns one + -> deserialize, validate, and capture required provenance + -> establish the intended owner relationship while still staged + -> perform the no-throw owner publication callback + -> merge prebuilt registry/saved-map nodes + -> mark the exact candidate incarnations Live +``` + +Staging is not gameplay publication: candidates are invisible to normal registry lookup until commit. An Area/GIT load uses one outer staged graph, so its nested factories and saved identities do not publish independently. Failure before object-level commit retires the staged candidates, restores the allocation cursor where required, and leaves no semantically live orphan or authoritative saved alias. + +Owned-graph replacement discovers the old closure and builds the new closure before a `noexcept` owner swap; publication then merges prebuilt nodes and retires the obsolete graph. Failure before the owner swap leaves the prior graph live. The guarantee addresses ordinary construction/validation failures, not transactional recovery from catastrophic process allocation failure. + +`Area::add()` is the Area ownership boundary. A successful call establishes the Area collection, type and Tag indexes, Room/Trigger relationships, and applicable scene/walkmesh attachments. An ordinary recoverable failure removes every relationship installed by that attempt and leaves semantic lifetime with the caller. When the object belongs to an outer staged Area graph it remains `Constructing` until the outer graph commits; adding an already live object changes Area ownership but not its runtime incarnation. + +This contract is generic over `ownedRuntimeObjects()` rather than a central Creature/Item type list. + +## 9. Presentation isolation + +Scene and GUI ownership do not create gameplay identity. + +- GUI previews use `Presentation` objects and presentation-only IDs. +- A character preview clones equipment into presentation Items; it never equips or re-owns the player's Item. +- Presentation objects are absent from the gameplay registry and saved identity maps. +- Scene attachment is an Area/presentation relationship and ends on detachment even if model storage remains. +- Retaining a node or preview after semantic destruction cannot make its Object executable or resolvable as gameplay state. + +Destination scene construction and attachment currently occur after the destination commit. They are not a privately staged duplicate scene. + +## 10. Destination preparation and publication + +C5 implements the minimum useful guarantee: + +> Enough destination resource and structural state is prepared and validated to reject avoidable structural failure before sacrificing the authoritative session. + +It does not privately construct a complete destination gameplay world. Specifically: + +### Prepared before commit + +- exact save-slot identity and archive index; +- mandatory save metadata and global-variable records; +- optional supplied Party/inventory records decoded for later use; +- for a template-world save, mandatory `pifo.ifo` player state and `AUTOSAVEPARAMS` timing/waypoint metadata; +- destination L source discovery and mount plan; +- IFO, ARE, GIT, and LYT availability and structural parsing; +- Party slot/member range and duplication constraints; +- authoritative saved graph ObjectId claims and allocator cursor constraints only when `GIT.UseTemplates` declares a saved module graph; +- source module snapshot candidate during an ordinary transition. + +### Deliberately post-commit + +- authoritative Party mutation and detached-creature materialization; +- runtime registry population and object-level staged graph construction; +- authoritative partial Module ownership, followed by Area/Room and scene construction; +- map, GUI, music, and other presentation changes; +- saved action/effect/event binding and mechanical/query-visible publication; +- authored spawn, `OnLoad`, and `OnEnter` behavior. + +C4 makes each object/owned graph correct at this post-commit boundary. The authoritative `_module` may therefore own a partial destination after the C5 commit without making incomplete child objects gameplay-live. A failure there coherently retires the partial destination and lands on the Main Menu; it does not resurrect an old world or claim arbitrary gameplay rollback. Constructing the complete world privately would duplicate the registry, Party, scene, and resource architecture and is deliberately not part of E. + +## 11. Actual transition pipelines + +### 11.1 Ordinary module transition + +```text +prepare destination L plan and validate IFO/ARE/GIT/LYT + -> source Area OnExit + -> build frozen source-module snapshot / candidate SaveWorkingState + -> capture supported Party action/delay continuation against source incarnations + -> if re-entering the same module, reprepare against that snapshot + -> prepare loading-screen presentation + -> COMMIT STARTS + -> retire source Area runtime and ordinary source runtime objects + -> retire source saved graph and Module + -> commit destination ActiveModule/ActiveModuleState resources + -> adopt the source SaveWorkingState candidate + -> clear/rebuild post-commit UI and scene state + -> establish destination identity context + -> stage/publish the destination Module and structural slot 0 when saved + -> stage the Area/GIT/nested construction graph, attach ownership, publish live objects + -> run fresh-module spawn scripts when applicable + -> establish a default Party when needed + -> bind saved/detached references and saved runtime state + -> install captured Party continuation and publish runtime state exactly once + -> run Module OnLoad + -> place Party, run fresh member spawn behavior when applicable, and run Area OnEnter + -> start music and publish Screen::InGame +``` + +The initial preflight occurs before source `OnExit`. The snapshot is adopted only after module resource publication succeeds. Re-entering the source module is the special case that must reprepare against the newly captured snapshot. Live Party actions never cross the Area boundary: only supported save-facing records and exact already-bound runtime dependencies are reconstructed. A destination restored from a prior module snapshot uses `ModuleGraph`; a destination selected from installed templates uses `Template`. + +### 11.2 Saved `ModuleGraph` disk load + +```text +exact SaveSlotDescriptor selected by discovery + -> prepareGameLoad: build unpublished SaveSessionState + -> decode/validate NFO and GLOBALVARS + -> decode supplied PartyTable and inventory records + -> prepare destination L plan and validate IFO/ARE/GIT/LYT + -> GIT.UseTemplates == 0: validate Party and authoritative ModuleGraph claims + -> COMMIT: reset/retire old runtime session + -> commitGameLoad: publish exact save session + -> commitModuleLoad: publish destination module resources + -> restore reputation, globals, custom tokens, saved namespace and retail clock + -> restore Party, PC/controlled representation, and inventory + -> stage/publish Module slot 0 and the Area/GIT/nested runtime graph + -> bind saved references and publish query/mechanically visible effects/actions/events + -> run Module OnLoad + -> place Party and run Area OnEnter + -> start music and publish Screen::InGame +``` + +The exact `SaveSlotDescriptor` is retained from enumeration through preparation and commit. Saved player placement is authoritative for this case. Entry hooks observe restored mechanical state and may query, clear, or replace it; later loader work does not resurrect state removed by a hook. + +### 11.3 Template-world disk autosave + +Retail transition autosaves may have a complete save session but no archived current-module graph. That is a distinct, valid topology: + +```text +prepare unpublished save session and installed destination resources + -> GIT.UseTemplates == 1: select Template world semantics + -> decode/validate pifo.ifo Mod_PlayerList and AUTOSAVEPARAMS + -> validate Party/save-wide state without reserving ModuleGraph IDs + -> COMMIT: reset old runtime and publish save/module resources + -> restore reputation, globals, tokens, PartyTable and inventory + -> restore canonical/current player representation from pifo.ifo as DetachedRecord + -> seed world time from TIME_PAUSEDAY/TIME_PAUSETIME + -> construct installed Module/Area/GIT objects with fresh runtime IDs + -> run fresh world-object spawn scripts + -> bind/publish supported detached runtime state + -> run Module OnLoad + -> place Party at AUTOSAVEPARAMS.STARTWAYPOINT, run member spawn behavior, and run Area OnEnter + -> start music and publish Screen::InGame +``` + +`*` denotes no explicit start waypoint. Template object numbers never enter saved-ID reservation or binding. The canonical PC remains distinct when `pifo.ifo` represents a controlled companion, and the saved authored companion Tag is preserved. Full disk `CreateParty` semantics clear detached follower NPC/PUP action queues where retail respawns those representations; this is intentionally different from ordinary module-travel continuation. + +### 11.4 Same-Area control transfer + +```text +resolve canonical PC or explicit roster slot + -> establish the explicit Party runtime binding/control state + -> transfer the outgoing leader transform to the incoming controlled actor + -> recalculate same-Area placement/camera state +``` + +There is no snapshot, resource activation, saved-graph retirement, or full Area retirement. + +### 11.5 Full runtime-session retirement + +```text +mark session non-playable and advance runtime-session generation + -> stop session gameplay/presentation controllers + -> release GUI-held runtime references + -> retire retained Party Area residency while Area owners exist + -> clear combat and transient Party runtime bindings + -> clear authoritative scene roots and Module ownership + -> semantically retire every exact registered runtime incarnation + -> retire saved graph/generation and clear session-published runtime-ID history + -> reset runtime allocators + -> clear save-wide mutable systems in resetGame + -> retire save/module resource ownership through L +``` + +Full session retirement is stronger than ordinary module retirement; ordinary travel preserves save-wide and logical Party state. + +## 12. Authored-script boundary + +The loader is not a transaction over arbitrary NWScript. + +| Hook | Boundary | +|---|---| +| Source Area `OnExit` | Runs after initial destination structural preflight but before source snapshot and irreversible commit | +| Fresh-object spawn scripts | Run only for a fresh/template world, after its runtime structure is authoritative | +| Module `OnLoad` | Runs after Module/Area/GIT publication and after saved/detached mechanical runtime state has been bound and published | +| Area `OnEnter` | Runs during Party placement after `OnLoad`, with the same published runtime state visible | + +Structural preparation executes no destination-authored script. Once destination scripts begin, the old world is intentionally unrecoverable. A script exception or later post-commit failure causes coherent destination/session retirement and a deliberate Main Menu state. + +Publishing saved Effects, Actions, events, and their bound references before entry hooks is semantically required. Shipped scripts query and clear those values. If publication occurred afterward, a hook would inspect an empty object and the loader would then resurrect state the hook intended to remove. Presentation-only effect attachment may follow Area placement where necessary, but mechanically/query-visible effect state does not. + +Source `OnExit` is a known weaker boundary: it runs against the authoritative source and arbitrary script mutation is not rollbackable if snapshot creation or same-module repreparation subsequently fails. The design rejects a generic script rollback journal as disproportionate and semantically misleading. + +## 13. Failure semantics + +| Failure phase | C5 world authority | Object-level result | Terminal behavior | +|---|---|---|---| +| Save cannot open/index or mandatory metadata fails | Current world preserved | Prepared save discarded | Error propagates; current screen/world remains | +| Destination module resources missing or IFO/ARE/GIT/LYT invalid | Current world preserved | Prepared module discarded | Ordinary transition returns failure; disk load rejects before reset | +| Template-autosave `pifo.ifo` or `AUTOSAVEPARAMS` invalid | Current world preserved | Prepared save/module discarded | Disk load rejects before reset | +| Pre-commit Party range/duplication or authoritative saved-ID validation fails | Current world preserved | No candidate runtime object exists | Load rejected before old-session retirement | +| Source `OnExit` throws | Source remains authoritative; prior script side effects may exist | No destination object exists | Ordinary transition returns failure | +| Source snapshot, Party-continuation capture, or same-module repreparation fails | Source remains authoritative; arbitrary `OnExit` mutation is not rolled back | Captured candidates discarded | Ordinary transition returns failure | +| Loading-screen setup fails before commit | Current world preserved | No destination object exists | Previous screen restored | +| Prepared resource plan changes/fails at commit | Old world retirement has started | Best-effort partial retirement | Main Menu with surfaced error | +| Post-commit object deserialization or saved-identity staging fails | Partial destination is authoritative but not playable | Staged object/graph discarded; no live orphan or alias | Partial destination retired; Main Menu | +| Post-commit `Area::add()` ordinary attachment fails | Partial destination is authoritative but not playable | Attempted Area indexes/tenancy/scene edges rolled back | Partial destination retired; Main Menu | +| Saved-runtime binding/publication fails | Old world is unavailable | Unpublished continuation fails closed; partial destination retired | Main Menu | +| Authored spawn/`OnLoad`/`OnEnter` fails | Destination had crossed the authored-gameplay boundary | Arbitrary gameplay is not rolled back | Destination retired; Main Menu | + +These are three different scopes: pre-C5 failure preserves the old world; post-C5 failure retires the partial destination; pre-object-commit failure additionally guarantees that the individual candidate never became semantically live. No stable boundary intentionally depends on a later registry or resource cleanup sweep. + +## 14. Lifetime hierarchy + +| Concept | Identity/lifetime authority | May alias? | Ends when | +|---|---|---:|---| +| Resource source | L `ResourceOwner` plus source identity | Same raw key may exist in several buckets/sources | Owner retires | +| Save session | Exact `SaveSlotDescriptor` and `SaveSessionState` | No active-session alias | Save replacement, new game, or abandonment | +| Saved graph | `SerializedIdentityContext(ModuleGraph)` plus saved-graph generation | Several serialized references may target one live object within the graph | Module saved graph retires | +| Detached record | `SerializedIdentityContext(DetachedRecord)` and record owner | Local numbers may repeat in other records | Record/materialization context ends | +| Logical roster member | `(RosterKind, PartyTable slot)` | Serialized representations may coexist | Logical Party state changes or session ends | +| Runtime incarnation | Runtime ObjectId, exact Object, incarnation, and registry | Numeric ID is not reused within the session; exact incarnation never aliases | Semantic retirement | +| Area residency | Area/Room/Trigger/Pathfinder and scene attachment | One live creature has at most one current residency | Full Area departure | +| Runtime Item | Exact Area residency or one inventory/container/equipment ownership edge, plus runtime incarnation | Area and nested ownership never coexist | Transfer replaces the disposition; consumption/owner retirement ends the Item as applicable | +| Presentation | GUI/scene owner and presentation-only object ID | May display copied gameplay data | Presentation closes or detaches | + +The session-published runtime-ID history is allocator policy inside the runtime-incarnation row, not an eighth identity domain. It ends with full runtime-session retirement. + +## 15. Required invariants and forbidden shortcuts + +The implementation must preserve these invariants: + +- serialized identity, runtime identity, and roster identity are independent; +- references bind only inside their declared graph/domain lifetime; +- one logical roster slot has at most one explicit live binding; +- logical availability does not confer ownership of, or destroy, a runtime representation; +- one semantically live object has one exact registry entry; +- one numeric runtime ID names at most one published incarnation per runtime session; +- Area-owned state ends before Area owners die; +- supported Party continuation is reconstructed from durable state rather than retaining outgoing live actions; +- nested ownership replacement is all-old or all-new at its owner boundary; +- each live Item has one stable ownership disposition, and Area residency cannot coexist with nested ownership; +- semantic destruction immediately ends runtime resolvability; +- an individual fallible object/Area graph cannot leave a live orphan or saved alias on failed construction; +- pre-commit structural rejection does not replace a good world; +- post-commit failure yields a coherent terminal state; +- K1/K2 differences are policy/data, especially roster and puppet cardinality. + +The following are architectural anti-patterns: + +- inferring generic roster identity from Tag or ResRef; +- inferring identity from equal saved, detached, or runtime ObjectIds; +- treating a template-world disk restore as an authoritative saved `ModuleGraph`; +- treating roster availability change as semantic Creature destruction; +- retaining Room, Trigger, Pathfinder, combat, or perception state across Area departure; +- retaining raw live action pointers across a module boundary instead of reconstructing supported continuation; +- treating `shared_ptr` storage as gameplay liveness; +- registering a fallible object before deserialization/validation; +- globally publishing candidate objects and relying on cleanup after ordinary construction failure; +- using one ambiguous “unload Party” operation for departure and repositioning; +- using registry membership as persistence identity; +- embedding KOTOR shared-inventory disposition inside generic Creature equipment ownership; +- building rollback machinery for arbitrary authored scripts; +- creating save-loader shadow versions of the complete Party, registry, scene, or engine. + +## 16. General-engine and future suitability + +These are general ownership/identity concepts, not KOTOR-save special cases. + +- Forward gameplay uses runtime liveness, Area residency, and ownership without requiring save/load. +- A new runtime object type participates by defining construction/deserialization, owned children, saved references, and optional Area/presentation behavior; central lifecycle code need not enumerate the type for retirement. +- A new equipment slot changes equipment schema/rules and serialization, not identity or registry semantics. +- A new content set or Retwo game can provide different resource/Party policies without inheriting Odyssey filesystem staging. +- A future incremental main-thread loader can pause at resource preparation, structural validation, commit, reconstruction, and playable publication boundaries. +- True concurrent/background preparation still requires isolation or synchronization around the temporarily mounted shared resource view. +- Multiplayer can add a network/replication identity domain beside saved, roster, and per-process runtime identities. Clients need not reproduce server runtime or saved IDs. + +The architecture does not claim async execution or multiplayer implementation today. + +## 17. Design rationale and complexity assessment + +The minimum concepts are: L resource ownership; serialized identity domain; logical roster slot; runtime incarnation/liveness; Area residency; owned object graph; and destination preparation/publication. Presentation is a separate ownership surface, not another gameplay identity. + +These concepts are independent rather than duplicate mechanisms: + +- saved-graph generation prevents serialized-reference lifetime leaks; +- runtime incarnation prevents dead-storage/runtime-ID aliasing; +- Area retirement ends residency without killing a retained object; +- Party binding preserves logical identity without relying on either ID; +- runtime-ID tombstoning strengthens the existing incarnation allocator contract rather than duplicating it; +- object-level staging and C5 preparation solve different scopes: exact-object publication versus avoidable destination rejection. + +Normal forward loading remains: + +```text +prepare -> construct/validate -> publish -> enter gameplay +``` + +The design deliberately rejected private duplicates of major subsystems, a second registry, arbitrary rollback journals, and script transactions. Consequently, some post-commit runtime failures go to Main Menu rather than preserving the old world. That is a smaller and more truthful guarantee. + +Save/load imposes explicit identity and lifetime contracts on runtime systems, but those contracts also make destruction, GUI previews, object references, inventory transfer, and ordinary module travel safer. None of the identity, roster, residency, liveness, or ownership mechanisms duplicates destination preparation. + +The final restoration and hardening work added no lifecycle domain, second registry, shadow Party, parallel scene, or rollback journal, and did not enlarge C5 into a whole-world transaction. Template-autosave metadata handling and detached Party action policy are necessarily KOTOR-facing loader policy, but they remain at the save/module boundary. Generic runtime code does not need to know that a session came from a save. Moving shared-inventory disposition out of `Creature` and into action/UI policy reduced an actual KOTOR-policy leak. + +The architecture is modestly larger than pre-E Reone because previously implicit identities and ownership edges are now enforceable. It cannot be materially simplified without collapsing demonstrated independent lifetimes—for example equating save restoration with a saved world, Party availability with object existence, or registry storage with liveness. Normal forward play still follows resource preparation, object construction/publication, Area residency, and authored entry without invoking save-specific reconstruction. + +## 18. Relationship to the original E design + +### Unchanged + +- Durable save, candidate working state, committed working state, active module resources, runtime reconstruction, and playable publication remain separate concepts. +- L primary selection, buckets, ownership, discovery, and rollback remain inputs to E rather than being duplicated. +- Raw resource visibility remains distinct from runtime restoration. +- Ordinary module transitions and full session replacement remain distinct lifetimes. +- Behavioral/content fidelity takes priority over museum restoration of Odyssey internals. +- Phase boundaries remain suitable for later incremental orchestration without making E an async project. + +### Clarified + +- `ModuleGraph`, `DetachedRecord`, and `Template` now define serialized reference authority. +- PartyTable slot identity, transient runtime binding, active membership, and controlled-PC topology are separate. +- Area residency and semantic object existence are separate lifetimes. +- Registry liveness and C++ storage lifetime are separate. +- Nested ownership and object-level staged publication have explicit guarantees. +- Playable publication and post-commit authored gameplay have an explicit failure policy. + +### Corrected or refined + +- Preflight now extends beyond opening `SAVEGAME.sav` to core destination module structure before old-session retirement. +- A saved session and an authoritative saved module world are independent; retail template-world autosaves restore save-wide state without inventing a `ModuleGraph`. +- Retail world time is restored and written through `Mod_PauseDay`/`Mod_PauseTime`, with autosave time coming from `AUTOSAVEPARAMS`. +- Saved mechanically/query-visible runtime state is bound and published before destination entry hooks. +- Source working-state mutation is adopted after destination resource publication rather than merely when a snapshot was produced. +- Full Area retirement is not used for same-Area control switching. +- Ordinary travel reconstructs supported Party action/delay continuation after retiring outgoing live execution state. +- Party availability mutation does not own or destroy an independently existing runtime creature. +- Item transfers and equipment replacement now express the displaced ownership receiver explicitly; generic Creature ownership no longer chooses KOTOR Party inventory policy. +- Runtime references no longer assume registry removal destroys all storage. +- Runtime numeric IDs are not reused within one session, and fallible Area/GIT graphs publish through object-level staging. +- The experimental automatic Tag-based GIT/roster reconciliation was rejected; it was not an original E macro principle. +- The final design does not promise a private whole-world transaction. Its bounded preparation guarantee is narrower and simpler. + +The evidence supports the conclusion that E's macro lifecycle design remained sound; implementation and correction work supplied identity, roster, residency, liveness, ownership, and publication contracts that the original design left under-specified. + +## 19. Remaining Loader roadmap boundaries + +- **F:** migrate remaining consumers of the Legacy resource container/backend to the common loader contract. +- **G:** delete the legacy resource architecture after migration and parity validation. +- **H/audit:** optional final enumeration, diagnostics, and cleanup once the cutover is complete. + +E must not absorb those backend-migration tasks. + +## 20. Deferred observations + +The following are intentionally separate from E correctness: + +- Area-runtime retirement still enumerates mixed Creature fields. A later incremental `GameObject` durable/runtime/component split can turn that procedural boundary into a smaller owned component without changing E's lifetime law. +- Retail writer parity for `Mod_NextCharId` remains future compatibility work. +- Some weather and transition-related saved GIT fields remain unmodelled. +- Adding a new first-class GIT object type still requires explicit parser/factory/loader registration; metadata-driven registration may reduce that central enumeration later. +- `TemporaryDiscovery` safely restores the current synchronous shared resource view, but true concurrent/background loading requires isolation or synchronization. +- Prepared plans carry selected source identities and decoded core GFFs rather than immutable copies of every later resource. External source mutation between preparation and commit follows the documented terminal policy. +- Source `OnExit` mutation is not rolled back if later snapshot or same-module repreparation fails. +- Actual async execution, replication/network identity, and deeper multiplayer authority remain later architectures. + +These are bounded roadmap items, not reasons to enlarge E into a component refactor, immutable content store, or whole-world transaction. diff --git a/include/reone/game/action.h b/include/reone/game/action.h index e7a8b1f11..0e4770835 100644 --- a/include/reone/game/action.h +++ b/include/reone/game/action.h @@ -18,6 +18,7 @@ #pragma once #include "savedruntime.h" +#include "runtimeref.h" #include "types.h" namespace reone { @@ -88,6 +89,12 @@ class Action : boost::noncopyable { return _savedAction; } + /** + * True while every non-owning gameplay object used by this action still + * denotes the exact live incarnation captured by the action. + */ + bool runtimeDependenciesLive() const; + protected: const float kDefaultMaxObjectDistance = 2.0f; const float kDistanceWalk = 4.0f; @@ -101,6 +108,9 @@ class Action : boost::noncopyable { bool _cancelled {false}; bool _locked {false}; std::optional _savedAction; + std::vector> _runtimeDependencies; + + void requireRuntimeObject(const std::shared_ptr &object); Action( Game &game, diff --git a/include/reone/game/action/attackobject.h b/include/reone/game/action/attackobject.h index 83afa220e..e13315f4f 100644 --- a/include/reone/game/action/attackobject.h +++ b/include/reone/game/action/attackobject.h @@ -39,7 +39,9 @@ class AttackObjectAction : public Action { bool passive = false) : Action(game, services, ActionType::AttackObject), _target(target), - _passive(passive), _services(services) {} + _passive(passive), _services(services) { + requireRuntimeObject(target); + } static bool classof(Action *from) { return from->type() == ActionType::AttackObject; diff --git a/include/reone/game/action/castfakespellatobject.h b/include/reone/game/action/castfakespellatobject.h index ae2527dde..c2f96de74 100644 --- a/include/reone/game/action/castfakespellatobject.h +++ b/include/reone/game/action/castfakespellatobject.h @@ -35,6 +35,7 @@ class CastFakeSpellAtObjectAction : public Action { _spell(spell), _target(std::move(target)), _projectilePathType(projectilePathType) { + requireRuntimeObject(_target); } static bool classof(Action *from) { diff --git a/include/reone/game/action/closedoor.h b/include/reone/game/action/closedoor.h index 03363b933..758c63a4f 100644 --- a/include/reone/game/action/closedoor.h +++ b/include/reone/game/action/closedoor.h @@ -18,6 +18,7 @@ #pragma once #include "../action.h" +#include "../object/door.h" namespace reone { @@ -32,6 +33,7 @@ class CloseDoorAction : public Action { std::shared_ptr door) : Action(game, services, ActionType::CloseDoor), _door(std::move(door)) { + requireRuntimeObject(_door); } static bool classof(Action *from) { diff --git a/include/reone/game/action/cutsceneattack.h b/include/reone/game/action/cutsceneattack.h index 3866efa01..76baf7957 100644 --- a/include/reone/game/action/cutsceneattack.h +++ b/include/reone/game/action/cutsceneattack.h @@ -30,7 +30,9 @@ class CutsceneAttackAction : public Action { const std::shared_ptr &target, int animation, AttackResultType result, int damage) : Action(game, services, ActionType::CutsceneAttack), - _target(target), _animation(animation), _result(result), _damage(damage) {} + _target(target), _animation(animation), _result(result), _damage(damage) { + requireRuntimeObject(target); + } static bool classof(Action *from) { return from->type() == ActionType::CutsceneAttack; diff --git a/include/reone/game/action/equipitem.h b/include/reone/game/action/equipitem.h index 52a837626..2e660a09d 100644 --- a/include/reone/game/action/equipitem.h +++ b/include/reone/game/action/equipitem.h @@ -18,6 +18,7 @@ #pragma once #include "../action.h" +#include "../object/item.h" namespace reone { @@ -32,6 +33,7 @@ class EquipItemAction : public Action { _item(std::move(item)), _inventorySlot(inventorySlot), _instant(instant) { + requireRuntimeObject(_item); } static bool classof(Action *from) { diff --git a/include/reone/game/action/equipmostdamagingmelee.h b/include/reone/game/action/equipmostdamagingmelee.h index cbabefea4..837d33975 100644 --- a/include/reone/game/action/equipmostdamagingmelee.h +++ b/include/reone/game/action/equipmostdamagingmelee.h @@ -29,6 +29,7 @@ class EquipMostDamagingMeleeAction : public Action { Action(game, services, ActionType::EquipMostDamagingMelee), _versus(std::move(versus)), _offHand(offHand) { + requireRuntimeObject(_versus); } static bool classof(Action *from) { diff --git a/include/reone/game/action/equipmostdamagingranged.h b/include/reone/game/action/equipmostdamagingranged.h index 74e061137..a11ac8add 100644 --- a/include/reone/game/action/equipmostdamagingranged.h +++ b/include/reone/game/action/equipmostdamagingranged.h @@ -28,6 +28,7 @@ class EquipMostDamagingRangedAction : public Action { EquipMostDamagingRangedAction(Game &game, ServicesView &services, std::shared_ptr versus) : Action(game, services, ActionType::EquipMostDamagingRanged), _versus(std::move(versus)) { + requireRuntimeObject(_versus); } static bool classof(Action *from) { diff --git a/include/reone/game/action/follow.h b/include/reone/game/action/follow.h index 90d0a7940..07801d1be 100644 --- a/include/reone/game/action/follow.h +++ b/include/reone/game/action/follow.h @@ -32,6 +32,7 @@ class FollowAction : public Action { Action(game, services, ActionType::Follow), _follow(std::move(follow)), _followDistance(followDistance) { + requireRuntimeObject(_follow); } static bool classof(Action *from) { diff --git a/include/reone/game/action/giveitem.h b/include/reone/game/action/giveitem.h index 570e4bf5c..e9069b2db 100644 --- a/include/reone/game/action/giveitem.h +++ b/include/reone/game/action/giveitem.h @@ -31,6 +31,8 @@ class GiveItemAction : public Action { Action(game, services, ActionType::GiveItem), _item(std::move(item)), _giveTo(std::move(giveTo)) { + requireRuntimeObject(_item); + requireRuntimeObject(_giveTo); } static bool classof(Action *from) { diff --git a/include/reone/game/action/interactobject.h b/include/reone/game/action/interactobject.h index d4ed48d89..24828cb77 100644 --- a/include/reone/game/action/interactobject.h +++ b/include/reone/game/action/interactobject.h @@ -29,6 +29,7 @@ class InteractObjectAction : public Action { InteractObjectAction(Game &game, ServicesView &services, std::shared_ptr placeable) : Action(game, services, ActionType::InteractObject), _placeable(std::move(placeable)) { + requireRuntimeObject(_placeable); } static bool classof(Action *from) { diff --git a/include/reone/game/action/jumptoobject.h b/include/reone/game/action/jumptoobject.h index 77ce54c2c..982153016 100644 --- a/include/reone/game/action/jumptoobject.h +++ b/include/reone/game/action/jumptoobject.h @@ -32,6 +32,7 @@ class JumpToObjectAction : public Action { Action(game, services, ActionType::JumpToObject), _toJumpTo(std::move(toJumpTo)), _walkStraightLine(walkStraightLine) { + requireRuntimeObject(_toJumpTo); } static bool classof(Action *from) { diff --git a/include/reone/game/action/lockobject.h b/include/reone/game/action/lockobject.h index e506102dc..73ed871d8 100644 --- a/include/reone/game/action/lockobject.h +++ b/include/reone/game/action/lockobject.h @@ -28,6 +28,7 @@ class LockObjectAction : public Action { LockObjectAction(Game &game, ServicesView &services, std::shared_ptr target) : Action(game, services, ActionType::Lock), _target(std::move(target)) { + requireRuntimeObject(_target); } static bool classof(Action *from) { diff --git a/include/reone/game/action/moveawayfromobject.h b/include/reone/game/action/moveawayfromobject.h index 330fb9046..d09fd09ea 100644 --- a/include/reone/game/action/moveawayfromobject.h +++ b/include/reone/game/action/moveawayfromobject.h @@ -34,6 +34,7 @@ class MoveAwayFromObject : public Action { _fleeFrom(std::move(fleeFrom)), _run(run), _moveAwayRange(moveAwayRange) { + requireRuntimeObject(_fleeFrom); } static bool classof(Action *from) { diff --git a/include/reone/game/action/opencontainer.h b/include/reone/game/action/opencontainer.h index 37f1e2cfc..541d727a6 100644 --- a/include/reone/game/action/opencontainer.h +++ b/include/reone/game/action/opencontainer.h @@ -30,6 +30,7 @@ class OpenContainerAction : public Action { std::shared_ptr object) : Action(game, services, ActionType::OpenContainer), _object(std::move(object)) { + requireRuntimeObject(_object); } static bool classof(Action *from) { diff --git a/include/reone/game/action/opendoor.h b/include/reone/game/action/opendoor.h index 83608039a..21236a24d 100644 --- a/include/reone/game/action/opendoor.h +++ b/include/reone/game/action/opendoor.h @@ -18,6 +18,7 @@ #pragma once #include "../action.h" +#include "../object/door.h" namespace reone { @@ -32,6 +33,7 @@ class OpenDoorAction : public Action { std::shared_ptr door) : Action(game, services, ActionType::OpenDoor), _door(std::move(door)) { + requireRuntimeObject(_door); } static bool classof(Action *from) { diff --git a/include/reone/game/action/openlock.h b/include/reone/game/action/openlock.h index b52e29003..d7dfde0f6 100644 --- a/include/reone/game/action/openlock.h +++ b/include/reone/game/action/openlock.h @@ -30,6 +30,7 @@ class OpenLockAction : public Action { std::shared_ptr target) : Action(game, services, ActionType::OpenLock), _target(std::move(target)) { + requireRuntimeObject(_target); } static bool classof(Action *from) { diff --git a/include/reone/game/action/pickupitem.h b/include/reone/game/action/pickupitem.h index 2652c7af7..436f212c6 100644 --- a/include/reone/game/action/pickupitem.h +++ b/include/reone/game/action/pickupitem.h @@ -31,6 +31,7 @@ class PickUpItemAction : public Action { std::shared_ptr item) : Action(game, services, ActionType::PickUpItem), _item(std::move(item)) { + requireRuntimeObject(_item); } static bool classof(Action *from) { diff --git a/include/reone/game/action/putdownitem.h b/include/reone/game/action/putdownitem.h index fce540d3c..446d699ed 100644 --- a/include/reone/game/action/putdownitem.h +++ b/include/reone/game/action/putdownitem.h @@ -31,6 +31,7 @@ class PutDownItemAction : public Action { std::shared_ptr item) : Action(game, services, ActionType::PutDownItem), _item(std::move(item)) { + requireRuntimeObject(_item); } static bool classof(Action *from) { diff --git a/include/reone/game/action/startconversation.h b/include/reone/game/action/startconversation.h index fe42b4d29..ee5ba1e75 100644 --- a/include/reone/game/action/startconversation.h +++ b/include/reone/game/action/startconversation.h @@ -50,6 +50,7 @@ class StartConversationAction : public Action { _barkX(barkX), _barkY(barkY), _dontClearAllActions(dontClearAllActions) { + requireRuntimeObject(_objectToConverse); } static bool classof(Action *from) { diff --git a/include/reone/game/action/takeitem.h b/include/reone/game/action/takeitem.h index 3392ad94b..3f7104f9c 100644 --- a/include/reone/game/action/takeitem.h +++ b/include/reone/game/action/takeitem.h @@ -31,6 +31,8 @@ class TakeItemAction : public Action { Action(game, services, ActionType::TakeItem), _item(std::move(item)), _takeFrom(std::move(takeFrom)) { + requireRuntimeObject(_item); + requireRuntimeObject(_takeFrom); } static bool classof(Action *from) { diff --git a/include/reone/game/action/unequipitem.h b/include/reone/game/action/unequipitem.h index 8263a8d87..bf989f430 100644 --- a/include/reone/game/action/unequipitem.h +++ b/include/reone/game/action/unequipitem.h @@ -18,6 +18,7 @@ #pragma once #include "../action.h" +#include "../object/item.h" namespace reone { @@ -31,6 +32,7 @@ class UnequipItemAction : public Action { Action(game, services, ActionType::UnequipItem), _item(std::move(item)), _instant(instant) { + requireRuntimeObject(_item); } static bool classof(Action *from) { diff --git a/include/reone/game/action/unlockobject.h b/include/reone/game/action/unlockobject.h index f6db7857b..c5e923ee8 100644 --- a/include/reone/game/action/unlockobject.h +++ b/include/reone/game/action/unlockobject.h @@ -28,6 +28,7 @@ class UnlockObjectAction : public Action { UnlockObjectAction(Game &game, ServicesView &services, std::shared_ptr target) : Action(game, services, ActionType::OpenLock), _target(std::move(target)) { + requireRuntimeObject(_target); } static bool classof(Action *from) { diff --git a/include/reone/game/action/usefeat.h b/include/reone/game/action/usefeat.h index 8d4a70817..4951a12fe 100644 --- a/include/reone/game/action/usefeat.h +++ b/include/reone/game/action/usefeat.h @@ -33,6 +33,7 @@ class UseFeatAction : public Action { Action(game, services, ActionType::UseFeat), _feat(feat), _target(std::move(target)) { + requireRuntimeObject(_target); } static bool classof(Action *from) { diff --git a/include/reone/game/action/useskill.h b/include/reone/game/action/useskill.h index 6e0718e28..850d4855e 100644 --- a/include/reone/game/action/useskill.h +++ b/include/reone/game/action/useskill.h @@ -37,6 +37,8 @@ class UseSkillAction : public Action { _target(std::move(target)), _subSkill(subSkill), _itemUsed(std::move(itemUsed)) { + requireRuntimeObject(_target); + requireRuntimeObject(_itemUsed); } static bool classof(Action *from) { diff --git a/include/reone/game/action/usetalentonobject.h b/include/reone/game/action/usetalentonobject.h index 0192b595f..58e8103c8 100644 --- a/include/reone/game/action/usetalentonobject.h +++ b/include/reone/game/action/usetalentonobject.h @@ -31,6 +31,8 @@ class UseTalentOnObjectAction : public Action { _chosenTalent(std::move(chosenTalent)), _target(std::move(target)) { + requireRuntimeObject(_target); + dispatchToAction(); } diff --git a/include/reone/game/d20/attributes.h b/include/reone/game/d20/attributes.h index d003ee7ed..ab9c25cc4 100644 --- a/include/reone/game/d20/attributes.h +++ b/include/reone/game/d20/attributes.h @@ -59,6 +59,9 @@ class CreatureAttributes { */ int getAggregateHitDie() const; + /** Derive permanent runtime vitality from a serialized base maximum. */ + int getPermanentMaxHitPoints(int baseHitPoints) const; + ClassType getEffectiveClass() const; int getClassLevel(ClassType clazz) const; int getAggregateAttackBonus() const; diff --git a/include/reone/game/effect.h b/include/reone/game/effect.h index cd466db55..1c3dc75d7 100644 --- a/include/reone/game/effect.h +++ b/include/reone/game/effect.h @@ -25,6 +25,8 @@ #include "reone/script/enginetype.h" +#include "saveprovenance.h" +#include "runtimeref.h" #include "types.h" namespace reone { @@ -33,6 +35,10 @@ namespace resource { class Gff; } +namespace script { +struct Variable; +} + namespace game { class Object; @@ -85,11 +91,42 @@ class Effect : public script::EngineType { virtual void applyTo(Object &object); virtual bool onApply(Object &object); virtual void onRemove(Object &object); + /** Release executable payload owned by the outgoing Area lifetime. */ + virtual void retireAreaRuntime( + const std::set &retainedObjects) {} + + /** + * Build the retail CGameEffect value carried by a live VM continuation. + * + * Runtime effects retain executable C++ behavior, while this description + * carries the orthogonal save-facing fields and object bindings. Subclasses + * configure their generic retail parameters through the protected helpers. + */ + virtual EffectInstance saveFacingInstance() const; + void setSaveFacingCreator(const std::shared_ptr &creator); + void captureSaveFacingScriptArguments( + const std::vector &arguments, + const Game &game); EffectType type() const { return _type; } protected: + void setSaveFacingInteger(size_t index, int32_t value); + void setSaveFacingFloat(size_t index, float value); + void setSaveFacingString(size_t index, std::string value); + void setSaveFacingObject( + size_t index, + const std::shared_ptr &object); + EffectType _type; + +private: + std::vector _saveFacingIntegers; + std::array _saveFacingFloats {}; + std::array _saveFacingStrings {}; + RuntimeObjectRef _saveFacingCreator; + std::array, 4> _saveFacingObjects; + bool _saveFacingRepresentable {true}; }; /** @@ -126,13 +163,20 @@ struct EffectInstance { kSavedEffectInvalidObjectId, kSavedEffectInvalidObjectId, }; - std::weak_ptr creator; + RuntimeObjectRef creator; + std::array, 4> objectParameterObjects; + std::optional serializedReferenceContext; - static EffectInstance fromGff(const resource::Gff &gff); + static EffectInstance fromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); DurationType durationType() const; uint16_t semanticSubType() const { return subType & 0x18; } bool hasStableId() const { return id != kUnassignedEffectId; } + bool hasSerializedObjectReferences() const { + return serializedReferenceContext.has_value(); + } bool shouldRestoreOnLoad() const { return !skipOnLoad && durationType() != DurationType::Equipped; } @@ -141,11 +185,26 @@ struct EffectInstance { expiryOrigin != EffectExpiryOrigin::None; } - std::shared_ptr boundCreator() const { return creator.lock(); } + std::shared_ptr boundCreator() const; + std::shared_ptr boundObjectParameter(size_t index) const; + + /** + * Rebase live object bindings at an Area lifetime boundary. + * + * Effects are durable gameplay state, but their object identities are not: + * a saved-graph identity and a module-owned runtime object both retire with + * the outgoing Area. Only bindings to explicitly retained session objects + * survive, rewritten as runtime-session identities. + */ + void retireAreaRuntimeBindings( + const std::set &retainedObjects); private: friend class Game; + std::optional _runtimeSession; + std::optional _savedGraph; bool bindCreator(const std::shared_ptr &object); + bool bindObjectParameter(size_t index, const std::shared_ptr &object); }; /** @@ -159,6 +218,7 @@ class SavedEffectValue : public Effect { public: explicit SavedEffectValue(EffectInstance instance); const EffectInstance &instance() const { return _instance; } + EffectInstance saveFacingInstance() const override { return _instance; } private: EffectInstance _instance; diff --git a/include/reone/game/effect/beam.h b/include/reone/game/effect/beam.h index bc285060a..e0e105507 100644 --- a/include/reone/game/effect/beam.h +++ b/include/reone/game/effect/beam.h @@ -18,6 +18,7 @@ #pragma once #include "../effect.h" +#include "../object.h" namespace reone { @@ -32,16 +33,27 @@ class BeamEffect : public Effect { bool missEffect) : Effect(EffectType::Beam), _beamVisualEffect(beamVisualEffect), - _effector(std::move(effector)), + _effector(effector), _bodyPart(bodyPart), _missEffect(missEffect) { + setSaveFacingInteger(0, beamVisualEffect); + setSaveFacingInteger(1, static_cast(bodyPart)); + setSaveFacingInteger(2, missEffect ? 1 : 0); + setSaveFacingObject(0, effector); } void applyTo(Object &object) override; + void retireAreaRuntime( + const std::set &retainedObjects) override { + auto effector = _effector.resolve(); + if (!effector || retainedObjects.count(effector.get()) == 0) { + _effector.reset(); + } + } private: int _beamVisualEffect; - std::shared_ptr _effector; + RuntimeObjectRef _effector; BodyNode _bodyPart; bool _missEffect; }; diff --git a/include/reone/game/effect/forcejump.h b/include/reone/game/effect/forcejump.h index 1b703ed00..7e1d62c1e 100644 --- a/include/reone/game/effect/forcejump.h +++ b/include/reone/game/effect/forcejump.h @@ -18,6 +18,7 @@ #pragma once #include "../effect.h" +#include "../object.h" namespace reone { @@ -27,15 +28,24 @@ class ForceJumpEffect : public Effect { public: ForceJumpEffect(std::shared_ptr target, int advanced) : Effect(EffectType::ForceJump), - _target(std::move(target)), + _target(target), _advanced(advanced) { + setSaveFacingInteger(0, advanced); + setSaveFacingObject(0, target); } void applyTo(Object &object) override { } + void retireAreaRuntime( + const std::set &retainedObjects) override { + auto target = _target.resolve(); + if (!target || retainedObjects.count(target.get()) == 0) { + _target.reset(); + } + } private: - std::shared_ptr _target; + RuntimeObjectRef _target; int _advanced; }; diff --git a/include/reone/game/effect/lightsaberthrow.h b/include/reone/game/effect/lightsaberthrow.h index 6ec396916..1a307031a 100644 --- a/include/reone/game/effect/lightsaberthrow.h +++ b/include/reone/game/effect/lightsaberthrow.h @@ -18,6 +18,7 @@ #pragma once #include "../effect.h" +#include "../object.h" namespace reone { @@ -31,18 +32,32 @@ class LightsaberThrowEffect : public Effect { std::shared_ptr target3, int advancedDamage) : Effect(EffectType::LightsaberThrow), - _target1(std::move(target1)), - _target2(std::move(target2)), - _target3(std::move(target3)), + _target1(target1), + _target2(target2), + _target3(target3), _advancedDamage(advancedDamage) { + // Retail accepts the fourth argument but does not persist it in the + // CGameEffect. The three object slots are the complete wire payload. + setSaveFacingObject(0, target1); + setSaveFacingObject(1, target2); + setSaveFacingObject(2, target3); } void applyTo(Object &object) override; + void retireAreaRuntime( + const std::set &retainedObjects) override { + for (auto *target : {&_target1, &_target2, &_target3}) { + auto object = target->resolve(); + if (!object || retainedObjects.count(object.get()) == 0) { + target->reset(); + } + } + } private: - std::shared_ptr _target1; - std::shared_ptr _target2; - std::shared_ptr _target3; + RuntimeObjectRef _target1; + RuntimeObjectRef _target2; + RuntimeObjectRef _target3; int _advancedDamage; }; diff --git a/include/reone/game/effect/linkeffects.h b/include/reone/game/effect/linkeffects.h index d007123c5..7b1db475a 100644 --- a/include/reone/game/effect/linkeffects.h +++ b/include/reone/game/effect/linkeffects.h @@ -32,6 +32,8 @@ class LinkEffectsEffect : public Effect { } void applyTo(Object &object) override; + void retireAreaRuntime( + const std::set &retainedObjects) override; private: std::shared_ptr _childEffect; diff --git a/include/reone/game/effect/visual.h b/include/reone/game/effect/visual.h index b3a0591ce..81f99dd56 100644 --- a/include/reone/game/effect/visual.h +++ b/include/reone/game/effect/visual.h @@ -36,6 +36,8 @@ class VisualEffect : public Effect { ~VisualEffect(); void applyTo(Object &object) override; + void retireAreaRuntime( + const std::set &retainedObjects) override; void setLocation(glm::vec3 loc) { _location = loc; } float duration() const; diff --git a/include/reone/game/equipmentrules.h b/include/reone/game/equipmentrules.h index 7100f499f..aed71445e 100644 --- a/include/reone/game/equipmentrules.h +++ b/include/reone/game/equipmentrules.h @@ -95,6 +95,25 @@ std::shared_ptr takeEquipmentCandidate( Object &inventory, const std::shared_ptr &item); +/** True only when the exact Item is owned by the active Area graph. */ +bool isActiveAreaOwnedItem( + Game &game, + const std::shared_ptr &item); + +/** End exact active-Area ownership without retiring the runtime Item. */ +bool releaseAreaOwnedItem( + Game &game, + const std::shared_ptr &item); + +/** + * Move one complete runtime Item from its current nested or Area ownership + * edge to receiver. An ownerless nonresident Item is not transferable. + */ +bool transferItemTo( + Game &game, + const std::shared_ptr &item, + Object &receiver); + } // namespace game } // namespace reone diff --git a/include/reone/game/event.h b/include/reone/game/event.h index 65be9353c..b11c05e75 100644 --- a/include/reone/game/event.h +++ b/include/reone/game/event.h @@ -19,10 +19,14 @@ #include "reone/script/enginetype.h" +#include "runtimeref.h" + namespace reone { namespace game { +class Object; + class Event : public script::EngineType { public: Event(int number) : @@ -33,26 +37,20 @@ class Event : public script::EngineType { std::vector integers, std::vector floats, std::vector strings, - std::vector objects) : - _number(number), - _integers(std::move(integers)), - _floats(std::move(floats)), - _strings(std::move(strings)), - _objects(std::move(objects)) { - } + std::vector> objects); int number() const { return _number; } const std::vector &integers() const { return _integers; } const std::vector &floats() const { return _floats; } const std::vector &strings() const { return _strings; } - const std::vector &objects() const { return _objects; } + std::vector objects() const; private: int _number; std::vector _integers; std::vector _floats; std::vector _strings; - std::vector _objects; + std::vector> _objects; }; } // namespace game diff --git a/include/reone/game/game.h b/include/reone/game/game.h index eb05aef42..f622b43be 100644 --- a/include/reone/game/game.h +++ b/include/reone/game/game.h @@ -94,10 +94,18 @@ class Texture; } +namespace resource { + +class PreparedModuleLoad; +class SaveWorkingState; + +} + namespace game { enum class ModuleLoadContext { FreshModule, + InitialTemplateRestore, InitialSaveRestore, SavedModuleTransition, }; @@ -108,6 +116,7 @@ ModuleLoadContext resolveModuleLoadContext( bool restoresSavedWorld(ModuleLoadContext context); bool restoresSavedSession(ModuleLoadContext context); +bool preservesSavedPlacement(ModuleLoadContext context); struct SavedObjectReference; struct SerializedScriptSituation; @@ -235,6 +244,21 @@ class Game : boost::noncopyable { void notifyLevelUpPending(const Creature &creature); void openContainer(const std::shared_ptr &container); void openPartySelection(const PartySelectionContext &ctx); + + /** + * Whether the current Area already contains one coherent live runtime + * representation for every requested logical NPC slot and no other + * selected companion. + */ + bool isPartySelectionRealized( + const std::vector &selectedNpcs) const; + + /** + * Reconcile requested logical NPC slots with exact roster bindings and + * current-Area residency. A coherent unchanged selection is a no-op. + */ + bool reconcilePartySelection( + const std::vector &selectedNpcs); void openSaveLoad(SaveLoadMode mode); void openGalaxyMap(int initialPlanet); /** Whether the galaxy map may take the screen over from the given one. */ @@ -326,7 +350,16 @@ class Game : boost::noncopyable { // Load a savegame. The slot is the durable identity discovered by // discoverSavedGames(); it is mounted verbatim rather than re-resolved, so // the slot the player picked is the slot that gets loaded. - void loadGame(const resource::SaveSlotDescriptor &slot); + bool loadGame(const resource::SaveSlotDescriptor &slot); + + struct PreparedDestinationModule { + std::string name; + std::unique_ptr resources; + std::shared_ptr ifo; + std::shared_ptr are; + std::shared_ptr git; + ModuleLoadContext context {ModuleLoadContext::FreshModule}; + }; /** * Candidate save load, resolved and validated but not yet committed. @@ -337,13 +370,21 @@ class Game : boost::noncopyable { * published. */ struct PreparedSaveLoad { + struct AutosaveRestoreState { + std::string startWaypoint; + uint32_t pauseDay {0}; + uint32_t pauseTime {0}; + }; + std::unique_ptr session; resource::NFO nfo; std::shared_ptr saveInfo; std::shared_ptr globalVars; - std::shared_ptr moduleIfo; std::shared_ptr partyTable; std::shared_ptr inventory; + std::shared_ptr playerInfo; + std::optional autosave; + PreparedDestinationModule destination; }; std::vector savedGames() const; @@ -376,6 +417,42 @@ class Game : boost::noncopyable { // Objects std::shared_ptr getObjectById(uint32_t id) const; + bool isRuntimeObjectLive(const Object &object) const; + + // End the semantic lifetime of this exact object and every runtime object + // it owns. Registry and saved-identity cleanup are pointer guarded, so a + // stale owner can never invalidate a newer object using the same number. + void destroyRuntimeObjectGraph(const std::shared_ptr &object); + + // Build replacement children outside the live registry, publish ownership + // with a no-throw swap, then atomically publish the candidates and retire + // obsolete children. A failed build leaves the old graph and all saved-ID + // bindings untouched. + template + void replaceRuntimeObjectGraph( + std::vector> &obsoleteObjects, + Build &&build, + Publish &&publish) { + static_assert( + noexcept(std::declval()()), + "runtime object graph publication must not throw"); + if (_stagedRuntimeObjectGraph) { + auto obsoleteGraph = collectRuntimeObjectGraph(obsoleteObjects); + build(); + publish(); + discardStagedRuntimeObjects(obsoleteGraph); + return; + } + beginRuntimeObjectGraphReplacement(obsoleteObjects); + try { + build(); + publish(); + commitRuntimeObjectGraphReplacement(obsoleteObjects); + } catch (...) { + abortRuntimeObjectGraphReplacement(); + throw; + } + } inline std::shared_ptr newModule() { return newObject(*this, _services); @@ -389,6 +466,9 @@ class Game : boost::noncopyable { inline std::shared_ptr newItem() { return newObject(*this, _services); } + inline std::shared_ptr newPresentationItem() { + return newPresentationObject(*this, _services); + } // Items serialized inside an owning inventory/equipment record use an // owner-local identity scope. Their saved ObjectId can legitimately match // another owned item or an independently registered world object, so the @@ -396,44 +476,61 @@ class Game : boost::noncopyable { inline std::shared_ptr newOwnedItem() { return newItem(); } - std::shared_ptr newItem(const resource::Gff &gff); + std::shared_ptr newOwnedItem( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); + std::shared_ptr newItem(const resource::Gff &gff, const SerializedIdentityContext &identityContext); + std::shared_ptr newItemFromBlueprint(const std::string &resRef); + std::shared_ptr newItemClone(const Item &source); inline std::shared_ptr newArea(std::string sceneName = kSceneMain) { return newObject(std::move(sceneName), *this, _services); } - inline std::shared_ptr newSavedArea(uint32_t id, std::string sceneName = kSceneMain) { - return newObjectAtId(id, true, std::move(sceneName), *this, _services); - } + std::shared_ptr newSavedArea( + uint32_t id, + const SerializedIdentityContext &identityContext, + std::string sceneName = kSceneMain); inline std::shared_ptr newCreature(std::string sceneName = kSceneMain) { return newObject(std::move(sceneName), *this, _services); } - std::shared_ptr newCreature(const resource::Gff &gff, std::string sceneName = kSceneMain); + inline std::shared_ptr newPresentationCreature( + std::string sceneName) { + return newPresentationObject( + std::move(sceneName), *this, _services); + } + std::shared_ptr newCreature(const resource::Gff &gff, const SerializedIdentityContext &identityContext, std::string sceneName = kSceneMain); + std::shared_ptr newCreatureFromBlueprint( + const std::string &resRef, + std::string sceneName = kSceneMain); inline std::shared_ptr newPlaceable(std::string sceneName = kSceneMain) { return newObject(std::move(sceneName), *this, _services); } - std::shared_ptr newPlaceable(const resource::Gff &gff, std::string sceneName = kSceneMain); + std::shared_ptr newPlaceable(const resource::Gff &gff, const SerializedIdentityContext &identityContext, std::string sceneName = kSceneMain); + std::shared_ptr newPlaceableFromBlueprint( + const std::string &resRef, + std::string sceneName = kSceneMain); inline std::shared_ptr newDoor(std::string sceneName = kSceneMain) { return newObject(std::move(sceneName), *this, _services); } - std::shared_ptr newDoor(const resource::Gff &gff, std::string sceneName = kSceneMain); + std::shared_ptr newDoor(const resource::Gff &gff, const SerializedIdentityContext &identityContext, std::string sceneName = kSceneMain); inline std::shared_ptr newWaypoint(std::string sceneName = kSceneMain) { return newObject(std::move(sceneName), *this, _services); } - std::shared_ptr newWaypoint(const resource::Gff &gff, std::string sceneName = kSceneMain); + std::shared_ptr newWaypoint(const resource::Gff &gff, const SerializedIdentityContext &identityContext, std::string sceneName = kSceneMain); inline std::shared_ptr newTrigger(std::string sceneName = kSceneMain) { return newObject(std::move(sceneName), *this, _services); } - std::shared_ptr newTrigger(const resource::Gff &gff, std::string sceneName = kSceneMain); + std::shared_ptr newTrigger(const resource::Gff &gff, const SerializedIdentityContext &identityContext, std::string sceneName = kSceneMain); inline std::shared_ptr newSound(std::string sceneName = kSceneMain) { return newObject(std::move(sceneName), *this, _services); } - std::shared_ptr newSound(const resource::Gff &gff, std::string sceneName = kSceneMain); + std::shared_ptr newSound(const resource::Gff &gff, const SerializedIdentityContext &identityContext, std::string sceneName = kSceneMain); inline std::shared_ptr newAnimatedCamera(std::string sceneName = kSceneMain) { return newObject(std::move(sceneName), *this, _services); @@ -458,15 +555,19 @@ class Game : boost::noncopyable { inline std::shared_ptr newEncounter(std::string sceneName = kSceneMain) { return newObject(std::move(sceneName), *this, _services); } - std::shared_ptr newEncounter(const resource::Gff &gff, std::string sceneName = kSceneMain); + std::shared_ptr newEncounter(const resource::Gff &gff, const SerializedIdentityContext &identityContext, std::string sceneName = kSceneMain); inline std::shared_ptr newStore(std::string sceneName = kSceneMain) { return newObject(std::move(sceneName), *this, _services); } - std::shared_ptr newStore(const resource::Gff &gff, std::string sceneName = kSceneMain); - - void prepareSavedRuntimeNamespace(const resource::Gff &ifo); - void reserveSavedObjectIds(const resource::Gff &gff); + std::shared_ptr newStore(const resource::Gff &gff, const SerializedIdentityContext &identityContext, std::string sceneName = kSceneMain); + + void prepareSavedRuntimeNamespace(const resource::Gff &ifo, const SerializedIdentityContext &identityContext); + void restoreWorldTime( + const resource::Gff &moduleIfo, + uint64_t pauseDay, + uint64_t pauseTime); + void reserveSavedObjectIds(const resource::Gff &gff, const SerializedIdentityContext &identityContext, SerializedGraphRoot graphRoot); void resolveSavedObjectReferences(); void bindSavedRuntimeState(); void publishSavedRuntimeState(); @@ -506,6 +607,18 @@ class Game : boost::noncopyable { */ void saveNpcState(int npc); + /** Persist one live creature as a detached PartyTable roster record. */ + void saveRosterState( + const RosterIdentity &identity, + const Creature &creature); + + /** Materialize an available, unbound slot from AVAILNPC/AVAILPUP. */ + std::shared_ptr materializeRosterCreature( + const RosterIdentity &identity); + + /** End a bound roster representation without changing availability. */ + bool killRosterCreature(const RosterIdentity &identity); + /** * Length of a game day in world-time milliseconds. * @@ -545,12 +658,25 @@ class Game : boost::noncopyable { template inline std::shared_ptr newObject(Args &&...args) { while (_objectById.count(_nextObjectId) || + _publishedRuntimeObjectIds.count(_nextObjectId) || + (_stagedRuntimeObjectGraph && + _stagedRuntimeObjectGraph->objectById.count(_nextObjectId)) || _reservedSavedObjectIds.count(_nextObjectId)) { ++_nextObjectId; } return newObjectAtId(_nextObjectId++, false, std::forward(args)...); } + /** Construct a presentation-only object outside the gameplay registry. */ + template + inline std::shared_ptr newPresentationObject(Args &&...args) { + auto object = std::make_shared( + _nextPresentationObjectId--, std::forward(args)...); + object->_runtimeState = Object::RuntimeState::Presentation; + object->_runtimeIncarnation = _nextRuntimeIncarnation++; + return object; + } + template inline std::shared_ptr newAction(Args &&...args) { return std::make_shared(*this, _services, std::forward(args)...); @@ -569,6 +695,14 @@ class Game : boost::noncopyable { size_t effectIdCount() const { return _effectIds.size(); } bool bindEffectCreator(EffectInstance &effect) const; bool bindSavedObjectReference(SavedObjectReference &reference) const; + std::shared_ptr resolveSerializedObjectReference( + uint32_t id, + const SerializedIdentityContext &identityContext) const; + std::shared_ptr getObjectBySavedId(uint32_t id) const; + void registerSavedObjectIdentity( + uint32_t id, + const std::shared_ptr &object, + const SerializedIdentityContext &identityContext); const SaveResourceShadows &saveResourceShadows() const { return _saveResourceShadows; @@ -629,19 +763,35 @@ class Game : boost::noncopyable { const resource::Gff &ifoGff) const; void replaceCustomTokens(std::map tokens); PreparedSaveLoad prepareSaveLoad(const resource::SaveSlotDescriptor &slot); - void restoreSaveLoad(PreparedSaveLoad prepared); + PreparedDestinationModule prepareDestinationModule( + const std::string &name, + bool initialSaveRestore, + std::shared_ptr workingState); + bool restoreSaveLoad(PreparedSaveLoad prepared); + bool loadPreparedModule( + PreparedDestinationModule prepared, + std::string entry, + bool initialSaveRestore, + bool resourcesCommitted, + std::shared_ptr sourceWorkingState = nullptr); + void validatePreparedDestination( + const PreparedDestinationModule &prepared) const; + void validatePartyLoad(const resource::Gff *partyTable) const; void retireToMainMenu(); void deserializeGlobalVariables(resource::Gff &gvtGff); - void deserializeParty(resource::Gff &ifoGff, const std::shared_ptr &ptGff); + void deserializeParty( + resource::Gff &ifoGff, + const std::shared_ptr &ptGff, + const SerializedIdentityContext &moduleIdentityContext); void publishPartyRuntimeState( resource::Gff &ifoGff, const std::shared_ptr &ptGff, - const std::shared_ptr &pcGff); + const std::shared_ptr &pcGff, + const SerializedIdentityContext &moduleIdentityContext); Party::PersistedState parsePartyTable(const resource::Gff &ptGff) const; void replacePartyTable(Party::PersistedState state); void deserializePazaakPartyTable(resource::Gff &ptGff); - void deserializeAvailableNpcs(); void deserializeGalaxyMap(resource::Gff &ptGff); void resetGalaxyMap(); void serializePazaakPartyTable(resource::Gff &ptGff) const; @@ -650,6 +800,8 @@ class Game : boost::noncopyable { void deserializeInventory(resource::Gff &inventoryGff); private: + friend class Area; + friend class Object; friend class TestGameModule; friend class ModuleSnapshotBuilder; friend class SaveWideSnapshotBuilder; @@ -723,11 +875,37 @@ class Game : boost::noncopyable { static constexpr uint32_t kFirstRuntimeObjectId = 2; // ids 0 and 1 are reserved uint32_t _nextObjectId {kFirstRuntimeObjectId}; std::map> _objectById; + // A numeric runtime ID names at most one incarnation during a runtime + // session. Saved cursors and developer-specified IDs may move allocation + // backwards, but cannot revive stale numeric gameplay references. + std::set _publishedRuntimeObjectIds; + std::map> _objectBySavedId; + // One authoritative graph object has one canonical saved identity. Roster + // doubles live in detached graphs and never create cross-graph aliases. + std::map _savedIdByObject; + struct StagedRuntimeObjectGraph { + uint32_t initialNextObjectId {kFirstRuntimeObjectId}; + std::map> objectById; + std::set publishedRuntimeObjectIds; + std::map> objectBySavedId; + std::map savedIdByObject; + std::set replaceableObjects; + std::set reservedSavedObjectIdsToRelease; + std::vector> obsoleteGraph; + std::vector> candidateObjects; + }; + std::optional _stagedRuntimeObjectGraph; + uint64_t _nextRuntimeIncarnation {1}; + uint32_t _nextPresentationObjectId { + std::numeric_limits::max() - 1}; std::set _reservedSavedObjectIds; + std::optional _reservedSavedIdentityNamespace; + std::map _reservedSavedObjectIdClaims; EffectIdNamespace _effectIds; bool _runtimeSessionPlayable {false}; bool _cheatUsed {false}; uint64_t _runtimeSessionGeneration {1}; + uint64_t _savedGraphGeneration {1}; bool _loadingFromSaveGame {false}; uint64_t _worldTimeMilliseconds {0}; uint8_t _minutesPerHour {5}; @@ -779,7 +957,7 @@ class Game : boost::noncopyable { std::unique_ptr _pazaakSession; std::optional _lastPazaakResult; - std::weak_ptr _pazaakContinuationCaller; + RuntimeObjectRef _pazaakContinuationCaller; Screen _pazaakOriginScreen {Screen::None}; bool _pazaakGUIsReady {false}; bool _pazaakDevelopmentLaunch {false}; @@ -796,7 +974,7 @@ class Game : boost::noncopyable { bool _pazaakPaceAutomaticDraws {true}; std::function _pazaakGuiLoadOverride; std::function _pazaakContinuationOverride; - std::weak_ptr _pazaakDevelopmentSelectedObjectOverride; + RuntimeObjectRef _pazaakDevelopmentSelectedObjectOverride; std::optional _pazaakOpponentDeckOverride; std::unique_ptr _map; @@ -838,7 +1016,8 @@ class Game : boost::noncopyable { void advanceWorldTime(float dt); void advancePlayedTime(float dt); - bool storeCurrentModuleForTransition(); + std::shared_ptr + prepareCurrentModuleWorkingState(); void processPendingSave(); void finalizeSaveRequest(const SaveRequest &request, SaveResult result); SaveResult executeSave(SaveRequest request); @@ -848,9 +1027,25 @@ class Game : boost::noncopyable { std::optional captureSaveScreenshot(); uint32_t savedObjectId(const resource::Gff &gff) const; + void retireSavedObjectGraph(); + void registerSavedModuleReferenceTarget( + const std::shared_ptr &module, + const SerializedIdentityContext &identityContext); void registerObject( const std::shared_ptr &object, bool allowReserved); + void beginRuntimeObjectGraphReplacement( + const std::vector> &obsoleteObjects); + void commitRuntimeObjectGraphReplacement( + const std::vector> &obsoleteObjects); + void abortRuntimeObjectGraphReplacement(); + void unregisterRuntimeObject(const std::shared_ptr &object); + bool isRuntimeObjectAttachable(const Object &object) const; + std::vector> collectRuntimeObjectGraph( + const std::vector> &roots) const; + void discardStagedRuntimeObjects( + const std::vector> &objects); + void retireActiveAreaRuntime(); template inline std::shared_ptr newObjectAtId( @@ -865,11 +1060,22 @@ class Game : boost::noncopyable { template inline std::shared_ptr newObjectFromGff( const resource::Gff &gff, + const SerializedIdentityContext &identityContext, Args &&...args) { - return newObjectAtId( - savedObjectId(gff), - true, - std::forward(args)...); + std::vector> noObsolete; + std::shared_ptr object; + replaceRuntimeObjectGraph( + noObsolete, + [&]() { + object = newObject(std::forward(args)...); + if (identityContext.hasAuthoritativeObjectIds()) { + registerSavedObjectIdentity( + savedObjectId(gff), object, identityContext); + } + object->deserialize(gff, identityContext); + }, + []() noexcept {}); + return object; } void loadDefaultParty(); bool loadParty(); diff --git a/include/reone/game/gui/container.h b/include/reone/game/gui/container.h index 4473cd111..b266e537c 100644 --- a/include/reone/game/gui/container.h +++ b/include/reone/game/gui/container.h @@ -24,6 +24,7 @@ #include "reone/gui/control/listbox.h" #include "../object.h" +#include "../runtimeref.h" namespace reone { @@ -38,7 +39,7 @@ class ContainerGUI : public GameGUI { void open(std::shared_ptr contanier); - Object &container() const { return *_container; } + Object &container() const; private: struct Controls { @@ -57,7 +58,7 @@ class ContainerGUI : public GameGUI { }; Mode _mode {Mode::ContainerToPlayer}; - std::shared_ptr _container; + RuntimeObjectRef _container; std::string _giveItemMsg; std::string _getItemsMsg; diff --git a/include/reone/game/gui/conversation.h b/include/reone/game/gui/conversation.h index 8b74bc590..4a60b4b0e 100644 --- a/include/reone/game/gui/conversation.h +++ b/include/reone/game/gui/conversation.h @@ -25,6 +25,7 @@ #include "../gui.h" #include "../object.h" +#include "../runtimeref.h" #include "../types.h" namespace reone { @@ -63,8 +64,9 @@ class Conversation : public GameGUI { void setAutoSkip(AutoSkip *skip) { _autoSkip = skip; } protected: + std::shared_ptr owner() const { return _owner.resolve(); } std::shared_ptr _dialog; - std::shared_ptr _owner; + RuntimeObjectRef _owner; std::shared_ptr _cameraModel; std::shared_ptr _lipAnimation; const resource::Dialog::EntryReply *_currentEntry {nullptr}; diff --git a/include/reone/game/gui/dialog.h b/include/reone/game/gui/dialog.h index 6fa7e98af..dfc2d6b52 100644 --- a/include/reone/game/gui/dialog.h +++ b/include/reone/game/gui/dialog.h @@ -46,7 +46,7 @@ class DialogGUI : public Conversation { private: struct Participant { std::shared_ptr model; - std::shared_ptr creature; + RuntimeObjectRef creature; bool mixedStuntActive {false}; glm::vec3 restorePosition {0.0f}; float restoreFacing {0.0f}; @@ -69,14 +69,14 @@ class DialogGUI : public Conversation { Controls _controls; - std::shared_ptr _currentSpeaker; + RuntimeObjectRef _currentSpeaker; std::map _participantByTag; /** * Ordinary creatures currently holding an authored cutscene pose. They keep * it until another authored animation replaces it or the dialogue ends. */ - std::vector> _heldCutParticipants; + std::vector> _heldCutParticipants; void preload(gui::IGUI &gui) override; void onGUILoaded() override; diff --git a/include/reone/game/gui/partyselect.h b/include/reone/game/gui/partyselect.h index 0d15e72a6..0f5820380 100644 --- a/include/reone/game/gui/partyselect.h +++ b/include/reone/game/gui/partyselect.h @@ -100,7 +100,6 @@ class PartySelection : public GameGUI { PartySelectionContext _context; int _selectedNpc {-1}; bool _added[kMaxNpcCount] {false}; - bool _baselineAdded[kMaxNpcCount] {false}; int _availableCount {0}; void onGUILoaded() override; @@ -164,7 +163,6 @@ class PartySelection : public GameGUI { } void addNpc(int npc); - void changeParty(); void refreshAcceptButton(); void refreshAvailableCount(); void refreshNpcButtons(); diff --git a/include/reone/game/messagebus.h b/include/reone/game/messagebus.h index 4496ec576..b3679e518 100644 --- a/include/reone/game/messagebus.h +++ b/include/reone/game/messagebus.h @@ -17,12 +17,15 @@ #pragma once +#include "reone/game/runtimeref.h" #include "reone/game/types.h" namespace reone { namespace game { +class Creature; + /** * MessageBus implements a queue of messages for SpeakString and * SetListenPattern. @@ -33,12 +36,18 @@ class MessageBus { * Adds a listener object for a pattern. The same object may listen for * multiple patterns, as long as it uses a different number for each. */ - void addListener(uint32_t listenerId, std::string pattern, int32_t number); + void addListener( + const std::shared_ptr &listener, + std::string pattern, + int32_t number); void addMessage(uint32_t speakerId, std::string msg, TalkVolume volume); - using OnMessage = std::function; + using OnMessage = std::function &listener, + int32_t number, + TalkVolume volume)>; /** * Process accumulated messages and call onMessage for each "matched" @@ -46,6 +55,9 @@ class MessageBus { */ void update(OnMessage onMessage); + /** Number of live listener-pattern registrations currently retained. */ + size_t listenerCount() const; + private: struct Message { uint32_t speakerId; @@ -54,7 +66,7 @@ class MessageBus { }; struct Listener { - uint32_t id; + RuntimeObjectRef object; int32_t number; }; @@ -63,6 +75,8 @@ class MessageBus { private: std::queue _pendingMessages; std::unordered_map _listeners; + + void pruneDeadListeners(); }; } // namespace game diff --git a/include/reone/game/modulesnapshot.h b/include/reone/game/modulesnapshot.h index 24bde63be..c9ff5691c 100644 --- a/include/reone/game/modulesnapshot.h +++ b/include/reone/game/modulesnapshot.h @@ -9,6 +9,7 @@ #include #include "reone/resource/id.h" +#include "reone/game/saveprovenance.h" #include "reone/system/types.h" namespace reone { @@ -69,17 +70,22 @@ struct ModuleSnapshotResult { /** Ordinary saved-object identity namespace shared by one module snapshot. */ class ModuleObjectIdContext { public: - void reserveWorldId(uint32_t id); - void reservePartyId(uint32_t id); - void retainItem(const Item &item); - void allocateItem(const Item &item); - uint32_t itemId(const Item &item) const; + explicit ModuleObjectIdContext(SerializedIdentityContext outputIdentityContext) : + _outputIdentityContext(std::move(outputIdentityContext)) { + } + + void assignContextObject(const Object &object, uint32_t id); + void retainObject(const Object &object, bool partyIdentity = false); + void allocateObject(const Object &object, bool partyIdentity = false); + uint32_t objectId(const Object &object) const; + bool contains(const Object &object) const; uint32_t nextId(uint32_t retainedCursor) const; private: + SerializedIdentityContext _outputIdentityContext; std::set _used; std::set _reservedPartyIds; - std::map _itemIds; + std::map _objectIds; }; /** @@ -113,36 +119,55 @@ class ModuleSnapshotBuilder { const ModuleObjectIdContext &ids) const; std::shared_ptr objectBase( const Object &object, resource::ResType templateType, uint32_t structType) const; - void writeObjectState(resource::Gff &record, const Object &object) const; + void writeObjectState( + resource::Gff &record, + const Object &object, + const ModuleObjectIdContext &ids) const; std::shared_ptr writeCreature( const Creature &creature, uint32_t structType, - std::optional serializedId) const; + std::optional serializedId, + const SerializedIdentityContext &outputIdentityContext) const; std::shared_ptr writeCreature( const Creature &creature, uint32_t structType, std::optional serializedId, const ModuleObjectIdContext &ids, bool includeInventory = true) const; - std::shared_ptr writeDoor(const Door &door) const; + std::shared_ptr writeDoor( + const Door &door, const ModuleObjectIdContext &ids) const; std::shared_ptr writePlaceable( const Placeable &placeable, const ModuleObjectIdContext &ids) const; std::shared_ptr writeItem( - const Item &item, uint32_t structType, std::optional serializedId) const; - std::shared_ptr writeTrigger(const Trigger &trigger) const; - std::shared_ptr writeEncounter(const Encounter &encounter) const; + const Item &item, + uint32_t structType, + std::optional serializedId, + const ModuleObjectIdContext *ids = nullptr) const; + std::shared_ptr writeTrigger( + const Trigger &trigger, const ModuleObjectIdContext &ids) const; + std::shared_ptr writeEncounter( + const Encounter &encounter, const ModuleObjectIdContext &ids) const; std::shared_ptr writeStore( const Store &store, const ModuleObjectIdContext &ids) const; - std::shared_ptr writeWaypoint(const Waypoint &waypoint) const; - std::shared_ptr writeSound(const Sound &sound) const; + std::shared_ptr writeWaypoint( + const Waypoint &waypoint, const ModuleObjectIdContext &ids) const; + std::shared_ptr writeSound( + const Sound &sound, const ModuleObjectIdContext &ids) const; std::shared_ptr writeCamera(const StaticCamera &camera) const; - bool isSerializedWorldObject(const Object &object) const; - uint32_t serializedReferenceId(const SavedObjectReference &reference) const; - EffectInstance normalizeEffectReferences(EffectInstance effect) const; - void normalizeSituationReferences(SerializedScriptSituation &situation) const; - void normalizeActionReferences(SavedActionRecord &action) const; - void normalizeEventReferences(SavedEventRecord &event) const; + uint32_t serializedReferenceId( + const SavedObjectReference &reference, + const ModuleObjectIdContext &ids) const; + EffectInstance normalizeEffectReferences( + EffectInstance effect, const ModuleObjectIdContext &ids) const; + void normalizeSituationReferences( + SerializedScriptSituation &situation, + const ModuleObjectIdContext &ids) const; + void normalizeActionReferences( + SavedActionRecord &action, const ModuleObjectIdContext &ids) const; + void normalizeEventReferences( + SavedEventRecord &event, const ModuleObjectIdContext &ids) const; void appendRuntimeDelayedEvents( std::vector &events, - const Object &owner) const; + const Object &owner, + const ModuleObjectIdContext &ids) const; void validate(const SavedModuleSnapshot &snapshot) const; }; diff --git a/include/reone/game/object.h b/include/reone/game/object.h index 082a60c97..cd6a6fefd 100644 --- a/include/reone/game/object.h +++ b/include/reone/game/object.h @@ -28,6 +28,7 @@ #include "action.h" #include "action/playanimation.h" #include "effect.h" +#include "runtimeref.h" #include "saveprovenance.h" #include "savedruntime.h" #include "types.h" @@ -50,13 +51,22 @@ class Room; class Object : public scene::IUser, boost::noncopyable { public: + enum class RuntimeState { + Constructing, + Live, + Retired, + Presentation, + }; + virtual ~Object() = default; static bool classof(Object *from) { return true; } - void deserialize(const resource::Gff &gff); + void deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); virtual void update(float dt); virtual void damage(int amount, uint32_t damager); @@ -87,6 +97,11 @@ class Object : public scene::IUser, boost::noncopyable { float getFacing() const { return glm::eulerAngles(_orientation).z; } uint32_t id() const { return _id; } + bool isRuntimeLive() const { return _runtimeState == RuntimeState::Live; } + bool isPresentationOnly() const { + return _runtimeState == RuntimeState::Presentation; + } + uint64_t runtimeIncarnation() const { return _runtimeIncarnation; } const std::string &tag() const { return _tag; } ObjectType type() const { return _type; } const std::string &blueprintResRef() const { return _blueprintResRef; } @@ -135,6 +150,11 @@ class Object : public scene::IUser, boost::noncopyable { const std::vector> &items() const { return _items; } + // Runtime children whose semantic lifetime is owned by this object. New + // nested game-object types participate in registry finalization by + // extending this list; Game does not need to know their concrete type. + virtual std::vector> ownedRuntimeObjects() const; + // END Inventory // Effects @@ -179,12 +199,12 @@ class Object : public scene::IUser, boost::noncopyable { // Maximum hit points, after considering all bonuses and penalties. int maxHitPoints() const { return _maxHitPoints; } - // Current hit points, not counting any bonuses. + // Current runtime hit points. int currentHitPoints() const { return _currentHitPoints; } void setMinOneHP(bool minOneHP) { _minOneHP = minOneHP; } - void setMaxHitPoints(int maxHitPoints) { _maxHitPoints = maxHitPoints; } - void setCurrentHitPoints(int hitPoints) { _currentHitPoints = hitPoints; } + virtual void setMaxHitPoints(int maxHitPoints) { _maxHitPoints = maxHitPoints; } + virtual void setCurrentHitPoints(int hitPoints) { _currentHitPoints = hitPoints; } // END Hit Points @@ -203,13 +223,17 @@ class Object : public scene::IUser, boost::noncopyable { const std::deque> &actions() const { return _actions; } std::vector saveActionSnapshot() const; + /** Drop live execution and object bindings after their Area was captured. */ + void retireAreaRuntimeState( + const std::set &retainedObjects); + // END Actions // Combat - uint32_t getLastHostileActor() const { return _lastHostileActor; } + uint32_t getLastHostileActor() const; - void setLastHostileActor(uint32_t actor) { _lastHostileActor = actor; } + void setLastHostileActor(uint32_t actor); // END Combat @@ -220,7 +244,9 @@ class Object : public scene::IUser, boost::noncopyable { const std::map &localBooleans() const { return _localBooleans; } const std::map &localNumbers() const { return _localNumbers; } - void deserializeRuntimeState(const resource::Gff &gff); + void deserializeRuntimeState( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); void bindSavedRuntimeState(); void publishSavedRuntimeState(); const std::vector &savedEffects() const { return _savedEffects; } @@ -229,10 +255,18 @@ class Object : public scene::IUser, boost::noncopyable { void captureSaveRecord( const resource::Gff &gff, + const SerializedIdentityContext &identityContext, SaveRecordOrigin origin = {}); const std::optional &saveRecordProvenance() const { return _saveRecordProvenance; } + std::optional serializedObjectIdentity() const { + return _saveRecordProvenance + ? _saveRecordProvenance->identity + : std::nullopt; + } + void assignSerializedObjectIdentity( + const SerializedObjectIdentity &identity); void resolveSavedReferences( @@ -262,14 +296,25 @@ class Object : public scene::IUser, boost::noncopyable { // END Scripts protected: + friend class Game; friend class ModuleSnapshotBuilder; friend class TestGameModule; + + // Add one privately constructed Item to a candidate owned graph using the + // same stacking rules as runtime inventory insertion. Authoritative saved + // identities can be preserved as distinct records during restoration. + std::shared_ptr appendOwnedItemCandidate( + std::vector> &items, + const std::shared_ptr &item, + bool preserveSerializedIdentities); struct DelayedAction { std::shared_ptr action; std::unique_ptr timer; }; uint32_t _id; + RuntimeState _runtimeState {RuntimeState::Constructing}; + uint64_t _runtimeIncarnation {0}; ObjectType _type; std::string _sceneName; Game &_game; @@ -324,13 +369,16 @@ class Object : public scene::IUser, boost::noncopyable { // END Actions - uint32_t _lastHostileActor {script::kObjectInvalid}; + RuntimeObjectRef _lastHostileActor; // Local variables std::map _savedReferenceIds; - std::map> _savedReferences; + std::map> _savedReferences; std::vector _savedEffects; SavedActionQueue _savedActionQueue; + SerializedIdentityContext _savedRuntimeIdentityContext; + std::vector _savedEffectReferencesBound; + std::vector _savedActionReferencesBound; bool _savedRuntimeParsed {false}; bool _savedRuntimePublished {false}; std::optional _saveRecordProvenance; @@ -357,6 +405,13 @@ class Object : public scene::IUser, boost::noncopyable { virtual void updateTransform(); virtual bool canExecuteActions() const { return true; } + void deserializeOwnedItems( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext, + SaveRecordOriginKind originKind, + bool forceDropable = false, + std::string originOwner = {}); + // Actions void updateActions(float dt); diff --git a/include/reone/game/object/area.h b/include/reone/game/object/area.h index 7b51614af..fa56b068a 100644 --- a/include/reone/game/object/area.h +++ b/include/reone/game/object/area.h @@ -77,7 +77,11 @@ class Area : public Object { return from->type() == ObjectType::Area; } - void load(std::string name, const resource::Gff &are, const resource::Gff &git, bool fromSave = false); + void load( + std::string name, + const resource::Gff &are, + const resource::Gff &git, + const SerializedIdentityContext &identityContext); void activate(); bool handle(const input::Event &event); @@ -127,6 +131,22 @@ class Area : public Object { std::shared_ptr createObject(ObjectType type, const std::string &blueprintResRef, const std::shared_ptr &location); + /** + * End this Area's ownership of an exact still-live runtime Object. + * + * This removes Area indexes, Room/Trigger tenancy and presentation + * attachment without firing authored exit behavior or ending semantic + * object lifetime. It is the transfer seam for a world object moving into + * another owning graph. + */ + bool releaseObject(const std::shared_ptr &object); + + /** Whether this exact Object is currently owned by this Area. */ + bool isObjectResident(const Object &object) const; + + /** Whether this exact runtime Object is queued for semantic destruction. */ + bool isObjectPendingDestruction(const Object &object) const; + bool isObjectSeen(const Creature &subject, const Object &object) const; ObjectList &getObjectsByType(ObjectType type); @@ -173,13 +193,23 @@ class Area : public Object { // Party - void unloadPartyMember(const std::shared_ptr &member); + /** End one retained creature's current Area lifetime. */ + void retireCreatureAreaRuntime(const std::shared_ptr &creature); + void retirePartyMemberAreaRuntime(const std::shared_ptr &member); void loadParty( const glm::vec3 &position, float facing, bool preserveSavedPlacement = false); - void unloadParty(); - void reloadParty(); + /** End every session-owned creature's residency in this Area. */ + void retirePartyAreaRuntime(); + /** Place a newly controlled actor without ending any existing Area lifetime. */ + void placeControlledCreature( + const std::shared_ptr &creature, + const glm::vec3 &position, + float facing); + /** Reconcile/position the selected party without ending this Area lifetime. */ + void repositionParty(const glm::vec3 &position, float facing); + void repositionParty(); // END Party @@ -323,8 +353,9 @@ class Area : public Object { void applySceneProperties(); void attachRoomToSceneGraph(Room &room); void attachObjectToSceneGraph(const std::shared_ptr &object); + void detachObjectRuntime(const std::shared_ptr &object); - void doDestroyObject(uint32_t objectId); + void doDestroyObject(uint32_t objectId, bool destroyRuntimeObject = true); void doDestroyObjects(); void updateVisibility(); void updateHeartbeat(float dt); @@ -333,6 +364,9 @@ class Area : public Object { const std::shared_ptr &member, int index, bool preserveSavedPlacement); + void repositionPartyMember( + const std::shared_ptr &member, + int index); glm::vec3 findPartyPosition(const Creature &member, const glm::vec3 &position) const; struct CreatureCollision { @@ -385,19 +419,22 @@ class Area : public Object { // Loading GIT - void loadGIT(const resource::generated::GIT &git, const resource::Gff &gff, bool fromSave); + void loadGIT( + const resource::generated::GIT &git, + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); void loadProperties(const resource::generated::GIT &git); - void loadCreatures(const resource::Gff &gff, bool fromSave); - void loadDoors(const resource::Gff &gff, bool fromSave); - void loadPlaceables(const resource::Gff &gff, bool fromSave); - void loadWaypoints(const resource::Gff &gff, bool fromSave); - void loadTriggers(const resource::Gff &gff, bool fromSave); - void loadSounds(const resource::Gff &gff, bool fromSave); - void loadCameras(const resource::Gff &gff, bool fromSave); - void loadEncounters(const resource::Gff &gff, bool fromSave); - void loadStores(const resource::Gff &gff, bool fromSave); - void loadItems(const resource::Gff &gff, bool fromSave); + void loadCreatures(const resource::Gff &gff, const SerializedIdentityContext &identityContext); + void loadDoors(const resource::Gff &gff, const SerializedIdentityContext &identityContext); + void loadPlaceables(const resource::Gff &gff, const SerializedIdentityContext &identityContext); + void loadWaypoints(const resource::Gff &gff, const SerializedIdentityContext &identityContext); + void loadTriggers(const resource::Gff &gff, const SerializedIdentityContext &identityContext); + void loadSounds(const resource::Gff &gff, const SerializedIdentityContext &identityContext); + void loadCameras(const resource::Gff &gff, const SerializedIdentityContext &identityContext); + void loadEncounters(const resource::Gff &gff, const SerializedIdentityContext &identityContext); + void loadStores(const resource::Gff &gff, const SerializedIdentityContext &identityContext); + void loadItems(const resource::Gff &gff, const SerializedIdentityContext &identityContext); // END Loading GIT diff --git a/include/reone/game/object/creature.h b/include/reone/game/object/creature.h index 6051ab53b..4f2ec48e4 100644 --- a/include/reone/game/object/creature.h +++ b/include/reone/game/object/creature.h @@ -33,6 +33,7 @@ #include "../d20/attributes.h" #include "../d20/itemattributes.h" #include "../object.h" +#include "../runtimeref.h" #include "../pathfinder.h" #include "item.h" @@ -74,16 +75,25 @@ class Creature : public Object, public scene::IAnimationEventListener { struct Perception { float sightRange {0.0f}; float hearingRange {0.0f}; - std::set seen; - std::set heard; + std::map> seen; + std::map> heard; + + bool sees(uint32_t id) const { + auto found = seen.find(id); + return found != seen.end() && found->second.resolve() != nullptr; + } + bool hears(uint32_t id) const { + auto found = heard.find(id); + return found != heard.end() && found->second.resolve() != nullptr; + } }; struct CombatState { bool active {false}; bool shouldDeactivate {false}; bool debilitated {false}; - std::shared_ptr attackTarget; - uint32_t attemptedAttackTarget {script::kObjectInvalid}; + RuntimeObjectRef attackTarget; + RuntimeObjectRef attemptedAttackTarget; ActionType attackAction {ActionType::QueueEmpty}; FeatType combatFeat {FeatType::Invalid}; Timer deactivationTimer; @@ -102,7 +112,18 @@ class Creature : public Object, public scene::IAnimationEventListener { void loadFromBlueprint(const std::string &resRef); void loadAppearance(); - void deserialize(const resource::Gff &gff); + void deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); + + /** + * Invalidate state that only exists while this retained creature inhabits + * one Area. The owning Area supplies its still-live Pathfinder so path + * handles are released before that owner is destroyed. + */ + void retireAreaRuntime( + Pathfinder &pathfinder, + const std::set &retainedObjects); void update(float dt) override; @@ -146,21 +167,33 @@ class Creature : public Object, public scene::IAnimationEventListener { NPCAIStyle aiStyle() const { return _aiStyle; } int walkmeshMaterial() const { return _walkmeshMaterial; } bool isPC() const { return _isPC; } + int assignedPuppet() const { return _assignedPuppet; } + bool isPuppet() const { return _puppet; } void setGender(Gender gender) { _gender = gender; } void setAppearance(int appearance) { _appearance = appearance; } void setMovementType(MovementType type); void setFaction(Faction faction) { _faction = faction; } + /** Set the serialized base maximum and preserve the existing damage. */ + void setMaxHitPoints(int baseHitPoints) override; + void setCurrentHitPoints(int hitPoints) override; + /** - * Complete retail primary-player publication after saved creature data has - * been read. Both Odyssey titles refill the authoritative primary player - * to the derived maximum here; ordinary creatures and detached PCs never - * pass through this operation. + * Recalculate permanent vitality after attributes, levels or feats change, + * preserving the exact amount of damage already sustained. */ - void restorePrimaryPlayerHitPoints(); + void recalculatePermanentVitality(); + + /** Initialize a newly generated creature at full derived vitality. */ + void initializeGeneratedVitality(); + + /** Current vitality translated back to the serialized base-HP axis. */ + int serializedCurrentHitPoints() const; void setMovementRestricted(bool restricted) { _movementRestricted = restricted; } void setImmortal(bool immortal) { _immortal = immortal; } void setAIStyle(NPCAIStyle style) { _aiStyle = style; } + void setAssignedPuppet(int puppet) { _assignedPuppet = puppet; } + void setPuppet(bool puppet) { _puppet = puppet; } void setWalkmeshMaterial(int material) { _walkmeshMaterial = material; } // Animation @@ -190,8 +223,24 @@ class Creature : public Object, public scene::IAnimationEventListener { // Equipment bool equip(const std::string &resRef); + /** Validate an equipment commit without changing ownership. */ + bool canEquip(int slot, const std::shared_ptr &item) const; bool equip(int slot, const std::shared_ptr &item); - void unequip(const std::shared_ptr &item); + /** Validate an equipment replacement without changing ownership. */ + bool canReplaceEquipment( + int slot, + const std::shared_ptr &item, + const Object &displacedReceiver) const; + bool replaceEquipment( + int slot, + const std::shared_ptr &item, + Object &displacedReceiver); + /** Remove the equipment ownership edge for immediate transfer or retirement. */ + std::shared_ptr takeEquippedItem(const std::shared_ptr &item); + /** Unequip an Item directly into an explicit owning inventory. */ + bool moveEquippedItemTo( + const std::shared_ptr &item, + Object &receiver); bool isSlotEquipped(int slot) const; @@ -199,6 +248,7 @@ class Creature : public Object, public scene::IAnimationEventListener { CreatureWieldType getWieldType() const; const std::map> &equipment() const { return _equipment; } + std::vector> ownedRuntimeObjects() const override; // END Equipment @@ -265,9 +315,17 @@ class Creature : public Object, public scene::IAnimationEventListener { int forcePoints() const { return _forcePoints; } int currentForce() const { return _currentForce; } - uint32_t getAttemptedAttackTarget() const { return _combatState.attemptedAttackTarget; } - std::shared_ptr getAttackTarget() const { return _combatState.attackTarget; } - uint32_t getLastHostileTarget() const { return _lastHostileTarget; } + uint32_t getAttemptedAttackTarget() const { + auto target = _combatState.attemptedAttackTarget.resolve(); + return target ? target->id() : script::kObjectInvalid; + } + std::shared_ptr getAttackTarget() const { + return _combatState.attackTarget.resolve(); + } + uint32_t getLastHostileTarget() const { + auto target = _lastHostileTarget.resolve(); + return target ? target->id() : script::kObjectInvalid; + } ActionType getLastAttackAction() const { return _lastAttackAction; } FeatType getLastCombatFeat() const { return _lastCombatFeat; } AttackResultType getLastAttackResult() const { return _lastAttackResult; } @@ -299,9 +357,7 @@ class Creature : public Object, public scene::IAnimationEventListener { void getMainHandDamage(int &min, int &max) const; void getOffhandDamage(int &min, int &max) const; - void setAttemptedAttackTarget(uint32_t target) { - _combatState.attemptedAttackTarget = target; - } + void setAttemptedAttackTarget(uint32_t target); void beginCombatAttack(std::shared_ptr target, FeatType feat); void finishCombatRound(); void setLastAttackResult(AttackResultType result) { _lastAttackResult = result; } @@ -373,6 +429,8 @@ class Creature : public Object, public scene::IAnimationEventListener { Gender _gender {Gender::Male}; uint16_t _portraitId {0}; bool _isPC {false}; + int32_t _assignedPuppet {-1}; + bool _puppet {false}; Faction _faction {Faction::Invalid}; bool _disarmable {false}; bool _noPermDeath {false}; @@ -449,7 +507,7 @@ class Creature : public Object, public scene::IAnimationEventListener { bool _movementRestricted {false}; CombatState _combatState; - uint32_t _lastHostileTarget {script::kObjectInvalid}; + RuntimeObjectRef _lastHostileTarget; ActionType _lastAttackAction {ActionType::QueueEmpty}; FeatType _lastCombatFeat {FeatType::Invalid}; AttackResultType _lastAttackResult {AttackResultType::Invalid}; @@ -497,6 +555,7 @@ class Creature : public Object, public scene::IAnimationEventListener { // disguise item's appearance when one is equipped, and restore the original // appearance when none remains. Updates _appearance only; callers rebuild the model. void updateDisguise(); + void updateEquipmentPresentation(); void updateCombat(float dt); void setLightsabersPowered(bool powered, bool animate); void updateLightsaberSoundPositions(); @@ -553,14 +612,21 @@ class Creature : public Object, public scene::IAnimationEventListener { // END Animation // Blueprint - void deserializeAll(const resource::Gff &gff); + void deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); void deserializeName(const resource::Gff &gff); void deserializeSoundSet(const resource::Gff &gff); void deserializeBodyBag(const resource::Gff &gff); void deserializeAttributes(const resource::Gff &gff); void deserializeClass(const resource::Gff &gff); void deserializePerception(const resource::Gff &gff); - void deserializeEquipItems(const resource::Gff &gff); + void deserializeOwnedItemsAndEquipment( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); + int derivePermanentMaxHitPoints() const; + void restoreSerializedVitality(); + void updateDeathFromCurrentHitPoints(); // END Blueprint }; diff --git a/include/reone/game/object/door.h b/include/reone/game/object/door.h index dee232668..fb392a092 100644 --- a/include/reone/game/object/door.h +++ b/include/reone/game/object/door.h @@ -49,7 +49,9 @@ class Door : public Object { } void loadFromBlueprint(const std::string &resRef); - void deserialize(const resource::Gff &gff); + void deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); bool isSelectable() const override; void damage(int amount, uint32_t damager) override; @@ -191,7 +193,9 @@ class Door : public Object { void runDamagedScript(uint32_t damagerId); void runDeathScript(uint32_t damagerId); - void deserializeAll(const resource::Gff &gff); + void deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); void loadAppearance(); /** Put model pose, walkmeshes and the open flag into correspondence with _state. */ diff --git a/include/reone/game/object/encounter.h b/include/reone/game/object/encounter.h index c0638107d..66163992e 100644 --- a/include/reone/game/object/encounter.h +++ b/include/reone/game/object/encounter.h @@ -62,7 +62,9 @@ class Encounter : public Object { return from->type() == ObjectType::Encounter; } - void deserialize(const resource::Gff &gff); + void deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); const SavedRuntimeState &savedRuntimeState() const { return _savedRuntimeState; } std::shared_ptr savedAreaObject(size_t index) const; @@ -106,7 +108,9 @@ class Encounter : public Object { SavedRuntimeState _savedRuntimeState; // END Serializable - void deserializeAll(const resource::Gff &gff); + void deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); void deserializeSavedRuntimeState(const resource::Gff &gff); void deserializeCreatures(const resource::Gff &gff); void deserializeGeometry(const resource::Gff &gff); diff --git a/include/reone/game/object/item.h b/include/reone/game/object/item.h index 4b13420b6..e602620b6 100644 --- a/include/reone/game/object/item.h +++ b/include/reone/game/object/item.h @@ -17,6 +17,8 @@ #pragma once +#include + #include "reone/audio/clip.h" #include "reone/audio/source.h" #include "reone/graphics/model.h" @@ -76,15 +78,10 @@ class Item : public Object { } void loadFromBlueprint(const std::string &resRef); - void deserialize(const resource::Gff &gff); - void captureOwnerLocalSaveRecord( + void deserialize( const resource::Gff &gff, - SaveRecordOrigin origin); - - std::optional originalOwnerLocalObjectId() const { - return _originalOwnerLocalObjectId; - } - void clone(Item &from); + const SerializedIdentityContext &identityContext); + void clone(const Item &from); void update(float dt) override; @@ -96,6 +93,8 @@ class Item : public Object { bool isEquippable() const; bool isEquippable(int slot) const; + bool isStackCompatibleWith(const Item &other) const; + bool mergeStackFrom(Item &other); bool isCredits() const { return _itemClass == kCreditsItemClass; } bool isDropable() const { return _dropable; } bool isIdentified() const { return _identified; } @@ -117,6 +116,7 @@ class Item : public Object { int modelVariation() const { return _modelVariation; } int numDice() const { return _numDice; } int stackSize() const { return _stackSize; } + int maxStackSize() const { return _maxStackSize; } int textureVariation() const { return _textureVariation; } std::shared_ptr ammunitionType() const { return _ammunitionType; } std::shared_ptr icon() const { return _icon; } @@ -169,6 +169,10 @@ class Item : public Object { uint32_t _ownerId {0}; + // baseitems.2da defines the retail repository limit. The permissive + // fallback preserves behavior for incomplete custom/test tables. + uint16_t _maxStackSize {std::numeric_limits::max()}; + std::string _baseBodyVariation; std::string _itemClass; @@ -203,12 +207,12 @@ class Item : public Object { int _activateSpellCost {0}; int _disguiseAppearance {-1}; std::vector _properties; - std::optional _originalOwnerLocalObjectId; - std::shared_ptr _audioSource; // Blueprint - void deserializeAll(const resource::Gff &gff); + void deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); void deserializeProperties(const resource::Gff &gff); void deserializeBase(const resource::Gff &gff); void loadAmmunitionType(); diff --git a/include/reone/game/object/module.h b/include/reone/game/object/module.h index b4bc532f4..354c2429c 100644 --- a/include/reone/game/object/module.h +++ b/include/reone/game/object/module.h @@ -71,12 +71,22 @@ class Module : public Object { } void load(std::string name, const resource::Gff &ifo, bool restoreSavedWorld = false); + // Structural load from records already validated by Game. Fresh-world + // spawn scripts are deliberately dispatched later via runSpawnScripts(), + // after the destination has crossed its publication boundary. + void load( + std::string name, + const resource::Gff &ifo, + const resource::Gff &are, + const resource::Gff &git, + bool restoreSavedWorld = false); void activate(); void loadParty( const std::string &entry = "", bool preserveSavedPlacement = false); void runOnLoadScript(); void runOnStartScript(); + void runSpawnScripts(); bool handle(const input::Event &event); void update(float dt); @@ -107,9 +117,13 @@ class Module : public Object { size_t pendingSavedEventCount() const; std::vector saveEventSnapshot() const; size_t enqueueSaveEvent(SavedEventRecord event); + size_t enqueueBoundSaveEvent( + SavedEventRecord event, bool referencesBound); bool cancelSaveEvent(size_t index); - void deserializeSavedEventQueue(const resource::Gff &ifo); + void deserializeSavedEventQueue( + const resource::Gff &ifo, + const SerializedIdentityContext &identityContext); void bindSavedEventQueue(); void publishSavedEventQueue(); void dispatchDueSavedEvents(); @@ -127,6 +141,7 @@ class Module : public Object { std::vector> _limboCreatures; SavedEventQueue _savedEventQueue; std::vector _savedEventLive; + std::vector _savedEventReferencesBound; struct PublishedSavedEvent { size_t savedIndex {0}; @@ -155,6 +170,8 @@ class Module : public Object { void loadInfo(const resource::generated::IFO &ifo); void loadArea( const resource::generated::IFO &ifo, + const resource::Gff &are, + const resource::Gff &git, bool restoreSavedWorld = false); void loadPlayer(); void loadLimboCreatures(const resource::Gff &ifo); diff --git a/include/reone/game/object/placeable.h b/include/reone/game/object/placeable.h index ee8141506..e1d212cd0 100644 --- a/include/reone/game/object/placeable.h +++ b/include/reone/game/object/placeable.h @@ -46,7 +46,9 @@ class Placeable : public Object { } void loadFromBlueprint(const std::string &resRef); - void deserialize(const resource::Gff &gff); + void deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); void damage(int amount, uint32_t damager) override; @@ -139,7 +141,9 @@ class Placeable : public Object { void runDamagedScript(uint32_t damagerId); void runDeathScript(uint32_t damagerId); - void deserializeAll(const resource::Gff &gff); + void deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); void loadAppearance(); void updateTransform() override; diff --git a/include/reone/game/object/sound.h b/include/reone/game/object/sound.h index 5f67da17b..a655c8856 100644 --- a/include/reone/game/object/sound.h +++ b/include/reone/game/object/sound.h @@ -50,7 +50,9 @@ class Sound : public Object { } void loadFromBlueprint(const std::string &resRef); - void deserialize(const resource::Gff &gff); + void deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); void update(float dt) override; @@ -100,7 +102,9 @@ class Sound : public Object { float _timeout {0.0f}; std::vector _sounds; - void deserializeAll(const resource::Gff &gff); + void deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); void loadAppearance(); void updateTransform() override; }; diff --git a/include/reone/game/object/store.h b/include/reone/game/object/store.h index c98ef44c7..953138299 100644 --- a/include/reone/game/object/store.h +++ b/include/reone/game/object/store.h @@ -44,7 +44,9 @@ class Store : public Object { return from->type() == ObjectType::Store; } - void deserialize(const resource::Gff &gff); + void deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); private: friend class ModuleSnapshotBuilder; @@ -56,8 +58,12 @@ class Store : public Object { uint8_t _buySellFlag {0}; // END Serializable - void deserializeAll(const resource::Gff &gff); - void deserializeItems(const resource::Gff &gff); + void deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); + void deserializeItems( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); }; } // namespace game diff --git a/include/reone/game/object/trigger.h b/include/reone/game/object/trigger.h index 8f414305c..4ab288120 100644 --- a/include/reone/game/object/trigger.h +++ b/include/reone/game/object/trigger.h @@ -54,7 +54,9 @@ class Trigger : public Object { } void loadFromBlueprint(const std::string &resRef); - void deserialize(const resource::Gff &gff); + void deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); void configureLinkedDoorTransition(const std::shared_ptr &door); void update(float dt) override; @@ -118,13 +120,15 @@ class Trigger : public Object { // END Serializable std::set> _tenants; - std::weak_ptr _linkedDoor; + RuntimeObjectRef _linkedDoor; bool _linkedDoorTransition {false}; float _debugTestAge {0.0f}; float _debugInsideAge {0.0f}; float _debugEnterAge {0.0f}; - void deserializeAll(const resource::Gff &gff); + void deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); void loadAppearance(); void syncDebugVisual(); diff --git a/include/reone/game/object/waypoint.h b/include/reone/game/object/waypoint.h index 05bdc588e..325a5c0e3 100644 --- a/include/reone/game/object/waypoint.h +++ b/include/reone/game/object/waypoint.h @@ -44,7 +44,9 @@ class Waypoint : public Object { } void loadFromBlueprint(const std::string &resRef); - void deserialize(const resource::Gff &gff); + void deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); bool isMapNoteEnabled() const { return _mapNoteEnabled; } @@ -59,7 +61,9 @@ class Waypoint : public Object { resource::LocString _locName; // END Serializable - void deserializeAll(const resource::Gff &gff); + void deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); }; } // namespace game diff --git a/include/reone/game/party.h b/include/reone/game/party.h index 1f32f9dd9..b02765b39 100644 --- a/include/reone/game/party.h +++ b/include/reone/game/party.h @@ -21,6 +21,8 @@ #include "reone/input/event.h" #include +#include +#include namespace reone { @@ -37,6 +39,29 @@ enum class XPSource { Console }; +enum class RosterKind { + Npc, + Puppet, +}; + +/** Save-wide identity of a companion record, independent of every ObjectId. */ +struct RosterIdentity { + RosterKind kind {RosterKind::Npc}; + int slot {-1}; + + bool operator<(const RosterIdentity &rhs) const { + return std::tie(kind, slot) < std::tie(rhs.kind, rhs.slot); + } + + bool operator==(const RosterIdentity &rhs) const { + return kind == rhs.kind && slot == rhs.slot; + } + + bool operator!=(const RosterIdentity &rhs) const { + return !operator==(rhs); + } +}; + class Party { public: // PARTYTABLE.res stores one ownership count per card type plus one spare @@ -148,6 +173,8 @@ class Party { */ void setControlledMember(int npc, const std::shared_ptr &creature); void setPersistedState(PersistedState state); + /** Retail LoadTableInfo semantics: persisted fields replace all bindings. */ + void loadPersistedState(PersistedState state); void setPartyLeader(int npc); void setPartyLeaderByIndex(int index); @@ -164,30 +191,113 @@ class Party { bool removeMember(int npc); + /** + * Persist and remove a TSL companion from the adventuring party before + * its runtime representation is retired by RemoveNPCFromPartyToBase. + * Logical availability and puppet assignment remain unchanged. + */ + bool removeMemberToBase(int npc); + bool isMember(int npc) const; bool isMember(const Object &object) const; + /** + * Whether this exact Creature is retained by Party/session lifetime across + * a module boundary instead of belonging to the outgoing module graph. + */ + bool isRetainedRuntimeRepresentation(const Creature &creature) const; + std::shared_ptr getMemberByNPC(int npc) const; std::shared_ptr getMember(int index) const; int getNPCByMemberIndex(int index) const; // END Members - // Available members + // Roster state and runtime bindings + + /** Whether this title owns the supplied logical roster slot. */ + bool isRosterIdentityValid(const RosterIdentity &identity) const; + + /** Persistent PartyTable availability; independent of runtime binding. */ + bool isRosterAvailable(const RosterIdentity &identity) const; + bool setRosterAvailable( + const RosterIdentity &identity, + bool available, + bool selectableWhenAdded = true); + + /** Persistent PartyTable selection policy for an available slot. */ + bool isRosterSelectable(const RosterIdentity &identity) const; + bool setRosterSelectable( + const RosterIdentity &identity, + bool selectable); + + /** + * Add or replace the detached persistent record from a live creature. + * Retail AddNPC/AddPUP does not implicitly bind the supplied module object. + */ + bool addAvailableRosterRecord( + const RosterIdentity &identity, + const std::shared_ptr &creature); + + /** Copy an authored UTC into a detached persistent roster record. */ + bool addAvailableRosterRecord( + const RosterIdentity &identity, + const std::string &blueprint); bool addAvailableMember(int npc, const std::string &blueprint); + /** + * Runtime-construction helper: make a slot available and bind this exact + * representation. Script AddAvailableNPCByObject deliberately uses + * addAvailableRosterRecord instead and does not bind its source object. + */ bool addAvailableMember(int npc, std::shared_ptr creature); bool removeAvailableMember(int npc); bool isMemberAvailable(int npc) const; std::shared_ptr getAvailableMember(int npc) const; + std::shared_ptr getAvailableMember( + int npc, bool loadIfMissing); // END Available members // Available puppets + /** K2 counterpart of the runtime-construction helper above. */ bool addAvailablePuppet(int puppet, std::shared_ptr creature); std::shared_ptr getAvailablePuppet(int puppet) const; + std::shared_ptr getAvailablePuppet( + int puppet, bool loadIfMissing); + + /** + * Publish one materialized creature as the sole runtime binding for a + * logical roster slot. Existing active-member views of that slot follow + * the binding; another logical slot may never bind the same creature. + */ + bool bindRosterCreature( + const RosterIdentity &identity, + const std::shared_ptr &creature); + bool clearRosterCreature( + const RosterIdentity &identity, + const Creature *expected = nullptr); + bool clearRosterCreature(const Creature &creature); + std::shared_ptr rosterCreature( + const RosterIdentity &identity) const; + std::shared_ptr rosterCreature( + const RosterIdentity &identity, + bool loadIfMissing); + std::optional rosterIdentity( + const Creature &creature) const; + + /** K2 active-puppet and assignment operations. */ + bool addPuppet(int puppet, const std::shared_ptr &creature); + bool removePuppet(int puppet); + bool isPuppet(int puppet) const; + bool assignPuppet(int puppet, int npc); + std::optional assignedNpcForPuppet(int puppet) const; + std::shared_ptr puppetOwner(int puppet) const; + + /** Complete live object graph retained by the session across Areas. */ + std::vector> runtimeObjects() const; // END Available puppets @@ -221,8 +331,8 @@ class Party { PazaakSideDeck sideDeck, size_t cardCount = kK1PazaakCardCount); void setPazaakSideDeck(PazaakSideDeck sideDeck); - /// Authored starting collection: two copies each of +1 through +5. - void setDefaultPazaakData(size_t cardCount = kK1PazaakCardCount); + /** Establish title-correct durable Party defaults for a fresh new game. */ + void initializeNewGameState(); // Experience // @@ -266,7 +376,7 @@ class Party { std::shared_ptr _player; std::shared_ptr _actualPlayer; - std::map> _availableMembers; + std::map> _npcBindings; std::vector _members; bool _solo {false}; int _gold {0}; @@ -277,9 +387,12 @@ class Party { PazaakCardCounts _pazaakCardCounts {}; PazaakSideDeck _pazaakSideDeck {}; PersistedState _persistedState; - std::map> _availablePuppets; + std::map> _puppetBindings; bool handleKeyDown(const input::KeyEvent &event); + bool makeRosterAvailableAndBind( + const RosterIdentity &identity, + const std::shared_ptr &creature); // Apply the party XP pool value to every current member's creature XP. void syncMembersXP(); diff --git a/include/reone/game/runtimeref.h b/include/reone/game/runtimeref.h new file mode 100644 index 000000000..857b2f6f9 --- /dev/null +++ b/include/reone/game/runtimeref.h @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2026 The reone project contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +namespace reone { + +namespace game { + +/** + * A non-owning reference to one exact published runtime-object incarnation. + * + * The weak pointer distinguishes storage identity, while the incarnation + * prevents a deliberately republished object from reviving an old reference. + * Runtime ObjectId is intentionally not part of resolution: a later object may + * legally reuse the same number without becoming the former target. + */ +template +class RuntimeObjectRef { +public: + RuntimeObjectRef() = default; + RuntimeObjectRef(const std::shared_ptr &object) { bind(object); } + + RuntimeObjectRef &operator=(const std::shared_ptr &object) { + bind(object); + return *this; + } + + void bind(const std::shared_ptr &object) { + _object = object; + _incarnation = object ? object->runtimeIncarnation() : 0; + } + + void reset() { + _object.reset(); + _incarnation = 0; + } + + std::shared_ptr resolve() const { + auto object = _object.lock(); + if (!object || !object->isRuntimeLive() || + object->runtimeIncarnation() != _incarnation) { + return nullptr; + } + return object; + } + + bool empty() const { return _incarnation == 0; } + uint64_t incarnation() const { return _incarnation; } + +private: + std::weak_ptr _object; + uint64_t _incarnation {0}; +}; + +} // namespace game + +} // namespace reone diff --git a/include/reone/game/savedruntime.h b/include/reone/game/savedruntime.h index 2b67a7b42..f0b955223 100644 --- a/include/reone/game/savedruntime.h +++ b/include/reone/game/savedruntime.h @@ -17,6 +17,7 @@ #include "reone/resource/gff.h" #include "effect.h" +#include "runtimeref.h" namespace reone { @@ -28,6 +29,10 @@ class Object; class SavedScriptSituationImporter; constexpr uint32_t kSavedRuntimeInvalidObjectId = 0x7f000000; +// Retail creates the structural Module before loading its saved object graph. +// Its contextual object-reference target is therefore slot 0 even though the +// IFO contains no owned Module record or explicit Module ObjectId field. +constexpr uint32_t kSavedRuntimeModuleObjectId = 0; /** A saved object identity which is deliberately unbound while B is built. */ struct SavedObjectReference { @@ -35,14 +40,32 @@ struct SavedObjectReference { SavedObjectReference() = default; explicit SavedObjectReference(uint32_t id) : id(id) {} + static SavedObjectReference fromRuntimeId(uint32_t id) { + return SavedObjectReference(id); + } + static SavedObjectReference fromSerializedId( + uint32_t id, + SerializedIdentityContext identityContext) { + SavedObjectReference result(id); + result._serializedIdentityContext = std::move(identityContext); + return result; + } bool isInvalid() const { return id == kSavedRuntimeInvalidObjectId; } - std::shared_ptr boundObject() const { return _object.lock(); } + bool isSerializedIdentity() const { + return _serializedIdentityContext.has_value(); + } + const std::optional &serializedIdentityContext() const { + return _serializedIdentityContext; + } + std::shared_ptr boundObject() const; private: friend class Game; - std::weak_ptr _object; + RuntimeObjectRef _object; std::optional _runtimeSession; + std::optional _savedGraph; + std::optional _serializedIdentityContext; }; struct SavedLocString { @@ -81,6 +104,14 @@ struct SavedStruct { }; struct UnsupportedSavedPayload { + /** + * Opaque compatibility shadow only. Reone never executes it and never + * guesses that an arbitrary DWORD is an ObjectId. Consequently, a modded + * unknown payload that embeds an undocumented object reference can only be + * preserved numerically; it is not promised to survive graph renumbering. + * Once a structure is known to carry references it must receive a typed + * model and symmetric translation instead of remaining here. + */ SavedStruct data; }; @@ -155,7 +186,9 @@ struct SerializedScriptSituation { std::vector stack; std::vector unsupportedFields; - static SerializedScriptSituation fromGff(const resource::Gff &gff); + static SerializedScriptSituation fromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); ScriptSituationResumeSupport resumeSupport() const { return ScriptSituationResumeSupport::ValidatedImport; } @@ -165,6 +198,7 @@ struct SerializedScriptSituation { private: friend class SavedScriptSituationImporter; std::optional _runtimeSession; + bool _referencesBound {false}; }; enum class SavedActionParameterType : uint32_t { @@ -188,7 +222,9 @@ struct SavedActionParameter { uint32_t type {0}; SavedActionParameterPayload payload {UnsupportedSavedPayload {}}; - static SavedActionParameter fromGff(const resource::Gff &gff); + static SavedActionParameter fromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); bool bindObjectReferences(const Game &game); }; @@ -205,7 +241,9 @@ struct SavedActionRecord { std::vector parameters; std::vector unsupportedFields; - static SavedActionRecord fromGff(const resource::Gff &gff); + static SavedActionRecord fromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); SavedExecutionSupport executionSupport() const; std::shared_ptr toRuntimeAction( Game &game, @@ -217,7 +255,10 @@ struct SavedActionRecord { struct SavedActionQueue { std::vector actions; - static SavedActionQueue fromGff(const resource::Gff &gff, const std::string &label = "ActionList"); + static SavedActionQueue fromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext, + const std::string &label = "ActionList"); }; enum class SavedEventType : uint32_t { @@ -256,10 +297,6 @@ struct SavedBytePayload { struct SavedIntPayload { int32_t value {0}; }; -struct SavedDwordPayload { - uint32_t value {0}; -}; - struct SavedSpellImpact { int32_t spellId {0}; SavedObjectReference caster; @@ -276,6 +313,32 @@ struct SavedBodyBag { glm::vec3 position {0.0f}; }; +/** Retail EVENT_BROADCAST_AOO stores an ObjectId in its generic DWORD Value. */ +struct SavedBroadcastAoo { + SavedObjectReference target; +}; + +/** + * Partially modelled CSWSCombatAttackData. + * + * The complete combat payload remains a compatibility shadow, while its two + * recovered ObjectId fields participate in normal graph translation. + */ +struct SavedCombatAttack { + SavedStruct data; + SavedObjectReference reactionObject; + SavedObjectReference ammoItem; +}; + +/** + * Partially modelled CSWCCMessageData used by EVENT_FEEDBACK_MESSAGE. + * Non-reference fields remain preserved in data. + */ +struct SavedFeedbackMessage { + SavedStruct data; + std::vector objects; +}; + using SavedEventPayload = std::variant< std::monostate, UnsupportedSavedPayload, @@ -283,10 +346,12 @@ using SavedEventPayload = std::variant< EffectInstance, SavedBytePayload, SavedIntPayload, - SavedDwordPayload, SavedSpellImpact, SavedScriptEvent, - SavedBodyBag>; + SavedBodyBag, + SavedBroadcastAoo, + SavedCombatAttack, + SavedFeedbackMessage>; struct SavedEventRecord { /** Absolute world/game timestamp, never a wall-clock or derived delay. */ @@ -298,7 +363,9 @@ struct SavedEventRecord { SavedEventPayload payload; std::vector unsupportedFields; - static SavedEventRecord fromGff(const resource::Gff &gff); + static SavedEventRecord fromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext); SavedExecutionSupport executionSupport() const; bool shouldRestore() const; bool bindObjectReferences(const Game &game); @@ -308,7 +375,10 @@ struct SavedEventRecord { struct SavedEventQueue { std::vector events; - static SavedEventQueue fromGff(const resource::Gff &gff, const std::string &label = "EventQueue"); + static SavedEventQueue fromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext, + const std::string &label = "EventQueue"); }; } // namespace game diff --git a/include/reone/game/saveprovenance.h b/include/reone/game/saveprovenance.h index 90fb56dd6..650427246 100644 --- a/include/reone/game/saveprovenance.h +++ b/include/reone/game/saveprovenance.h @@ -15,6 +15,7 @@ #include #include #include +#include #include "reone/resource/gff.h" @@ -42,6 +43,71 @@ class SaveGffShadow { void replaceSaveField(resource::Gff &record, resource::Gff::Field field); void removeSaveField(resource::Gff &record, std::string_view label); +/** + * Authority carried by ObjectId fields in one materialization context. + * + * The GFF field type alone does not answer this question. Saved module graphs + * own an authoritative object namespace, detached records carry only local + * serialization identities, and templates carry no serialized instance + * identity at all. + */ +enum class SerializedIdentityDomain { + ModuleGraph, + DetachedRecord, + Template, +}; + +struct SerializedIdentityContext { + SerializedIdentityDomain domain {SerializedIdentityDomain::Template}; + std::string identityNamespace; + + static SerializedIdentityContext moduleGraph(std::string identityNamespace) { + return {SerializedIdentityDomain::ModuleGraph, std::move(identityNamespace)}; + } + static SerializedIdentityContext detachedRecord(std::string identityNamespace) { + return {SerializedIdentityDomain::DetachedRecord, std::move(identityNamespace)}; + } + static SerializedIdentityContext templateResource(std::string identityNamespace = {}) { + return {SerializedIdentityDomain::Template, std::move(identityNamespace)}; + } + + bool isSerializedState() const { + return domain != SerializedIdentityDomain::Template; + } + bool hasAuthoritativeObjectIds() const { + return domain == SerializedIdentityDomain::ModuleGraph; + } + + bool operator==(const SerializedIdentityContext &rhs) const { + return domain == rhs.domain && identityNamespace == rhs.identityNamespace; + } +}; + +struct SerializedObjectIdentity { + SerializedIdentityContext context; + uint32_t id {0}; + + bool operator==(const SerializedObjectIdentity &rhs) const { + return context == rhs.context && id == rhs.id; + } +}; + +enum class SerializedGraphRoot { + ModuleIfo, + AreaGit, +}; + +struct SerializedObjectIdClaim { + uint32_t id {0}; + std::string path; +}; + +/** Collect only owned identities, never ObjectId-shaped reference fields. */ +std::vector collectSerializedObjectIdClaims( + const resource::Gff &root, + const SerializedIdentityContext &context, + SerializedGraphRoot graphRoot); + enum class SaveRecordOriginKind { ActiveGitObject, ModulePlayer, @@ -68,6 +134,7 @@ struct SaveRecordOrigin { struct SaveRecordProvenance { SaveGffShadow shadow; SaveRecordOrigin origin; + std::optional identity; }; enum class SaveResourceKind { diff --git a/include/reone/game/types.h b/include/reone/game/types.h index b4b52528b..6aded643c 100644 --- a/include/reone/game/types.h +++ b/include/reone/game/types.h @@ -568,6 +568,7 @@ enum class FeatType { ImprovedDueling = 114, MasterDueling = 115, ImprovedToughness = 123, + MasterToughness = 124, // TSL @@ -641,6 +642,8 @@ enum class FeatType { ForceChain = 205, WarVeteran = 206, ComplexUnarmedAnims = 207, + ImprovedWookieEndurance = 224, + MasterWookieEndurance = 225, FightingSpirit = 236, HeroicResolve = 237, PreciseShot = 240, diff --git a/include/reone/resource/director.h b/include/reone/resource/director.h index 3119279ed..70c6d1c44 100644 --- a/include/reone/resource/director.h +++ b/include/reone/resource/director.h @@ -42,12 +42,67 @@ struct ScriptServices; namespace resource { class IDialogs; +class Gff; class IGffs; class ILips; class IPaths; class IResources; class IScripts; class ITwoDAs; +class PreparedModuleLoadTestFactory; + +/** + * A module mount and its required structural records, prepared without + * retiring the active module's resource owners. + * + * The mount plan remains private to L. Game receives only the records it needs + * to validate and later construct the destination. Runtime objects, Party and + * scene state are intentionally not duplicated here. + */ +class PreparedModuleLoad { +public: + const std::string &moduleName() const { return _moduleName; } + const std::shared_ptr &moduleIfo() const { return _moduleIfo; } + const std::shared_ptr &areaAre() const { return _areaAre; } + const std::shared_ptr &areaGit() const { return _areaGit; } + bool structurallyValidated() const { return _structurallyValidated; } + +private: + friend class ResourceDirector; + friend class PreparedModuleLoadTestFactory; + + // Test fixtures may supply already-decoded records, but production code + // can only obtain a validated candidate through ResourceDirector's L + // discovery, mount and structural-validation path. + PreparedModuleLoad( + std::string moduleName, + std::shared_ptr moduleIfo, + std::shared_ptr areaAre, + std::shared_ptr areaGit) : + _moduleName(std::move(moduleName)), + _moduleIfo(std::move(moduleIfo)), + _areaAre(std::move(areaAre)), + _areaGit(std::move(areaGit)), + _structurallyValidated(true) { + } + + PreparedModuleLoad( + std::string moduleName, + RuntimeModuleSourceIndex sources, + ModuleLoadPlan plan) : + _moduleName(std::move(moduleName)), + _sources(std::move(sources)), + _plan(std::move(plan)) { + } + + std::string _moduleName; + RuntimeModuleSourceIndex _sources; + ModuleLoadPlan _plan; + std::shared_ptr _moduleIfo; + std::shared_ptr _areaAre; + std::shared_ptr _areaGit; + bool _structurallyValidated {false}; +}; /** * Whether a game's Odyssey sources are placed in the raw lookup order. @@ -92,6 +147,18 @@ class IResourceDirector { /** Publish a prepared candidate, retiring the previous save mounts. */ virtual void commitGameLoad(std::unique_ptr candidate) = 0; + + /** + * Validate the destination module's effective IFO/ARE/GIT/LYT view while + * the current resource session remains authoritative. + */ + virtual std::unique_ptr prepareModuleLoad( + const std::string &name, + std::shared_ptr workingState) = 0; + + /** Publish a previously prepared module mount. */ + virtual void commitModuleLoad( + std::unique_ptr candidate) = 0; virtual std::optional findSaveMetadata(const ResourceId &id) = 0; virtual std::optional findSaveWorking(const ResourceId &id) = 0; virtual std::unordered_set saveWorkingResourceIds() const = 0; @@ -150,6 +217,11 @@ class ResourceDirector : public IResourceDirector, boost::noncopyable { std::unique_ptr prepareGameLoad( const SaveSlotDescriptor &slot) override; void commitGameLoad(std::unique_ptr candidate) override; + std::unique_ptr prepareModuleLoad( + const std::string &name, + std::shared_ptr workingState) override; + void commitModuleLoad( + std::unique_ptr candidate) override; std::optional findSaveMetadata(const ResourceId &id) override; std::optional findSaveWorking(const ResourceId &id) override; std::unordered_set saveWorkingResourceIds() const override; @@ -203,11 +275,21 @@ class ResourceDirector : public IResourceDirector, boost::noncopyable { std::unique_ptr buildSaveSession(const SaveSlotDescriptor &slot); void commitSaveSession(std::unique_ptr candidate); - void loadModuleResources(const std::string &name); - void loadModuleResourcesFromPolicy(const std::string &name); + std::unique_ptr planModuleLoad( + const std::string &name, + std::shared_ptr workingState); + ModuleMountReport mountModuleLoad( + const PreparedModuleLoad &candidate, + bool temporary); + void logModuleMount( + const PreparedModuleLoad &candidate, + const ModuleMountReport &report) const; std::vector moduleSearchRoots(); - void addStagedModuleSources(const std::string &moduleRoot, RuntimeModuleSourceIndex &index); + void addStagedModuleSources( + const std::string &moduleRoot, + const std::shared_ptr &workingState, + RuntimeModuleSourceIndex &index); bool includeModuleInSave(const std::string &moduleRoot); }; diff --git a/include/reone/resource/extractresources.h b/include/reone/resource/extractresources.h index 7f9e15b87..d7cd2a05b 100644 --- a/include/reone/resource/extractresources.h +++ b/include/reone/resource/extractresources.h @@ -73,6 +73,9 @@ class ExtractResources : public IResources, boost::noncopyable { Resource get(const ResourceId &id) override; std::optional find(const ResourceId &id) override; + std::optional findExcludingOwners( + const ResourceId &id, + const std::set &excludedOwners) override; size_t sourceCount() const { return _sources.size(); } diff --git a/include/reone/resource/replacementresources.h b/include/reone/resource/replacementresources.h index bc7d2f979..3446fd17c 100644 --- a/include/reone/resource/replacementresources.h +++ b/include/reone/resource/replacementresources.h @@ -59,6 +59,9 @@ class ReplacementResources : public IResources, boost::noncopyable { Resource get(const ResourceId &id) override; std::optional find(const ResourceId &id) override; + std::optional findExcludingOwners( + const ResourceId &id, + const std::set &excludedOwners) override; private: std::unique_ptr _backend; diff --git a/include/reone/resource/resources.h b/include/reone/resource/resources.h index 6d9fdb9ad..f9a90517a 100644 --- a/include/reone/resource/resources.h +++ b/include/reone/resource/resources.h @@ -24,6 +24,8 @@ #include "resource.h" #include "sourcelist.h" +#include + namespace reone { namespace resource { @@ -96,6 +98,19 @@ class IResources { virtual Resource get(const ResourceId &id) = 0; virtual std::optional find(const ResourceId &id) = 0; + + /** + * Resolve through the normal lookup order while ignoring sources whose + * lifetime owner is excluded. + * + * Destination preflight uses this to inspect a temporarily mounted module + * beside the still-authoritative one. The candidate keeps its real bucket + * precedence (including loose overrides), while the outgoing module's + * generic resource names cannot satisfy the lookup accidentally. + */ + virtual std::optional findExcludingOwners( + const ResourceId &id, + const std::set &excludedOwners) = 0; }; class Resources : public IResources, boost::noncopyable { @@ -144,6 +159,9 @@ class Resources : public IResources, boost::noncopyable { Resource get(const ResourceId &id) override; std::optional find(const ResourceId &id) override; + std::optional findExcludingOwners( + const ResourceId &id, + const std::set &excludedOwners) override; const ResourceContainerList &containers() const { return _containers; } diff --git a/src/libs/game/CMakeLists.txt b/src/libs/game/CMakeLists.txt index 9f67711ab..2081bc94c 100644 --- a/src/libs/game/CMakeLists.txt +++ b/src/libs/game/CMakeLists.txt @@ -182,6 +182,7 @@ set(GAME_HEADERS ${GAME_INCLUDE_DIR}/effect/whirlwind.h ${GAME_INCLUDE_DIR}/equipmentrules.h ${GAME_INCLUDE_DIR}/event.h + ${GAME_INCLUDE_DIR}/runtimeref.h ${GAME_INCLUDE_DIR}/footstepsounds.h ${GAME_INCLUDE_DIR}/floatingtext.h ${GAME_INCLUDE_DIR}/galaxymapstate.h @@ -476,6 +477,7 @@ set(GAME_SOURCES ${GAME_SOURCE_DIR}/effect/vpregenmodifier.cpp ${GAME_SOURCE_DIR}/effect/whirlwind.cpp ${GAME_SOURCE_DIR}/effect.cpp + ${GAME_SOURCE_DIR}/event.cpp ${GAME_SOURCE_DIR}/equipmentrules.cpp ${GAME_SOURCE_DIR}/footstepsounds.cpp ${GAME_SOURCE_DIR}/floatingtext.cpp diff --git a/src/libs/game/action.cpp b/src/libs/game/action.cpp index a4fb6e242..e61d29d59 100644 --- a/src/libs/game/action.cpp +++ b/src/libs/game/action.cpp @@ -17,14 +17,30 @@ #include "reone/game/action.h" +#include + #include "reone/game/action/usefeat.h" #include "reone/game/attack.h" +#include "reone/game/object.h" #include "reone/system/logutil.h" namespace reone { namespace game { +void Action::requireRuntimeObject(const std::shared_ptr &object) { + if (object) { + _runtimeDependencies.emplace_back(object); + } +} + +bool Action::runtimeDependenciesLive() const { + return std::all_of( + _runtimeDependencies.begin(), + _runtimeDependencies.end(), + [](const auto &reference) { return reference.resolve() != nullptr; }); +} + void Action::execute(std::shared_ptr self, Object &actor, float dt) { warn("Action execution not implemented: " + std::to_string(static_cast(_type))); complete(); diff --git a/src/libs/game/action/attackobject.cpp b/src/libs/game/action/attackobject.cpp index 299eca46e..b8a3a2c6e 100644 --- a/src/libs/game/action/attackobject.cpp +++ b/src/libs/game/action/attackobject.cpp @@ -228,7 +228,7 @@ std::optional AttackObjectAction::saveFacingState() const { result.declaredParameterCount = 10; result.parameters = { {1, int32_t {0}}, - {3, SavedObjectReference {_target->id()}}, + {3, SavedObjectReference::fromRuntimeId(_target->id())}, {1, int32_t {1}}, {1, int32_t {10009}}, {1, int32_t {1500}}, diff --git a/src/libs/game/action/castspellatobject.cpp b/src/libs/game/action/castspellatobject.cpp index 271a39ddd..f095c7e68 100644 --- a/src/libs/game/action/castspellatobject.cpp +++ b/src/libs/game/action/castspellatobject.cpp @@ -38,7 +38,12 @@ CastSpellAtObjectAction::CastSpellAtObjectAction( _target(std::move(target)), _item(std::move(item)), _schedule(_spell->conjTime, _spell->castTime), - _cheat(cheat) {} + _cheat(cheat) { + requireRuntimeObject(_target); + if (_item) { + requireRuntimeObject(*_item); + } +} static void runScript(Game &game, const Spell &spell, const Object &target) { if (spell.impactScript.empty()) { @@ -86,6 +91,9 @@ void CastSpellAtObjectAction::execute(std::shared_ptr self, Object &acto finish(caster); return; } + if (lastItem) { + _game.destroyRuntimeObjectGraph(_item.value()); + } } caster.setMovementType(Creature::MovementType::None); diff --git a/src/libs/game/action/commonactions.cpp b/src/libs/game/action/commonactions.cpp index c065cfec3..da9d9ce17 100644 --- a/src/libs/game/action/commonactions.cpp +++ b/src/libs/game/action/commonactions.cpp @@ -30,7 +30,7 @@ namespace reone { namespace game { -void tryUnlockDoorWithKey(Door &door, Object &actor, Party &party) { +void tryUnlockDoorWithKey(Game &game, Door &door, Object &actor, Party &party) { if (!door.isLocked() || !door.isKeyRequired() || door.keyName().empty()) { return; } @@ -48,8 +48,11 @@ void tryUnlockDoorWithKey(Door &door, Object &actor, Party &party) { } door.setLocked(false); if (door.isAutoRemoveKey()) { - bool last; + bool last = false; keyOwner->removeItem(key, last); + if (last) { + game.destroyRuntimeObjectGraph(key); + } } } diff --git a/src/libs/game/action/commonactions.h b/src/libs/game/action/commonactions.h index 6fc8838c2..0d5e73068 100644 --- a/src/libs/game/action/commonactions.h +++ b/src/libs/game/action/commonactions.h @@ -30,7 +30,7 @@ class Placeable; // If the door is locked and requires a named key, look for that key in the // actor's inventory and then the party player's, unlock the door if found, and // consume one key when AutoRemoveKey is set. Leaves the door locked otherwise. -void tryUnlockDoorWithKey(Door &door, Object &actor, Party &party); +void tryUnlockDoorWithKey(Game &game, Door &door, Object &actor, Party &party); // Move an actor to a door, unlock and open it. Returns true when this action is // complete. diff --git a/src/libs/game/action/equipitem.cpp b/src/libs/game/action/equipitem.cpp index af72330eb..e55f8b3c9 100644 --- a/src/libs/game/action/equipitem.cpp +++ b/src/libs/game/action/equipitem.cpp @@ -16,7 +16,10 @@ */ #include "reone/game/action/equipitem.h" +#include "reone/game/equipmentrules.h" +#include "reone/game/game.h" #include "reone/game/object/creature.h" +#include "reone/game/object/item.h" namespace reone { @@ -28,7 +31,79 @@ void EquipItemAction::execute(std::shared_ptr self, Object &actor, float complete(); return; } - creature->equip(_inventorySlot, _item); + auto candidate = _item; + int equipabilitySlot = _inventorySlot; + if (_inventorySlot == InventorySlots::rightWeapon2) { + equipabilitySlot = InventorySlots::rightWeapon; + } else if (_inventorySlot == InventorySlots::leftWeapon2) { + equipabilitySlot = InventorySlots::leftWeapon; + } + if (!creature->isRuntimeLive() || !candidate || + !candidate->isRuntimeLive() || + !candidate->isEquippable(equipabilitySlot)) { + complete(); + return; + } + for (const auto &[slot, equipped] : creature->equipment()) { + if (equipped == candidate && slot != _inventorySlot) { + complete(); + return; + } + } + + auto actorObject = _game.getObjectById(actor.id()); + auto displacedReceiver = + _game.party().sharedInventoryReceiver(actorObject); + auto previous = creature->getEquippedItem(_inventorySlot); + if (previous && previous != candidate && !displacedReceiver) { + complete(); + return; + } + + std::shared_ptr sourceOwner; + uint32_t ownerId = candidate->owner(); + bool areaOwned = isActiveAreaOwnedItem(_game, candidate); + if (areaOwned && ownerId != 0 && ownerId != script::kObjectInvalid) { + complete(); + return; + } + if (ownerId != 0 && ownerId != script::kObjectInvalid) { + sourceOwner = _game.getObjectById(ownerId); + if (!sourceOwner) { + complete(); + return; + } + if (auto equippedOwner = + std::dynamic_pointer_cast(sourceOwner); + equippedOwner && candidate->isEquipped()) { + candidate = equippedOwner->takeEquippedItem(candidate); + } else { + candidate = takeEquipmentCandidate(_game, *sourceOwner, candidate); + } + } else { + bool ready = previous && previous != candidate + ? displacedReceiver && + creature->canReplaceEquipment( + _inventorySlot, candidate, *displacedReceiver) + : creature->canEquip(_inventorySlot, candidate); + if (!ready || !areaOwned || + !releaseAreaOwnedItem(_game, candidate)) { + complete(); + return; + } + } + bool equipped = candidate && + (previous && previous != candidate + ? creature->replaceEquipment( + _inventorySlot, candidate, *displacedReceiver) + : creature->equip(_inventorySlot, candidate)); + if (!equipped) { + if (candidate && candidate->owner() == 0 && sourceOwner) { + sourceOwner->addItem(candidate); + } + complete(); + return; + } creature->playAnimation(CombatAnimation::Draw, creature->getWieldType()); complete(); } diff --git a/src/libs/game/action/movetolocation.cpp b/src/libs/game/action/movetolocation.cpp index 56c51449f..23974dd55 100644 --- a/src/libs/game/action/movetolocation.cpp +++ b/src/libs/game/action/movetolocation.cpp @@ -113,8 +113,8 @@ std::optional MoveToLocationAction::saveFacingState() const { {2, _destination->position().x}, {2, _destination->position().y}, {2, _destination->position().z}, - {3, SavedObjectReference {areaId}}, - {3, SavedObjectReference {kSavedRuntimeInvalidObjectId}}, + {3, SavedObjectReference::fromRuntimeId(areaId)}, + {3, SavedObjectReference::fromRuntimeId(kSavedRuntimeInvalidObjectId)}, {1, flags}, {2, 0.0f}, {1, int32_t {0}}, diff --git a/src/libs/game/action/movetoobject.cpp b/src/libs/game/action/movetoobject.cpp index f474f113d..132d48331 100644 --- a/src/libs/game/action/movetoobject.cpp +++ b/src/libs/game/action/movetoobject.cpp @@ -38,6 +38,7 @@ MoveToObjectAction::MoveToObjectAction( _range(range), _force(force), _timeout(timeout) { + requireRuntimeObject(_moveTo); if (_moveTo) { _forcedState.destination = _moveTo->position(); } @@ -53,6 +54,7 @@ MoveToObjectAction::MoveToObjectAction( _force(true), _timeout(timeout), _forcedState(std::move(forcedState)) { + requireRuntimeObject(_moveTo); } void MoveToObjectAction::execute(std::shared_ptr self, Object &actor, float dt) { @@ -135,7 +137,7 @@ std::optional MoveToObjectAction::saveFacingState() const { result.declaredParameterCount = 13; result.parameters = { {2, destination.x}, {2, destination.y}, {2, destination.z}, - {3, SavedObjectReference {areaId}}, {3, SavedObjectReference {_moveTo->id()}}, + {3, SavedObjectReference::fromRuntimeId(areaId)}, {3, SavedObjectReference::fromRuntimeId(_moveTo->id())}, {1, flags}, {2, _range}, {1, int32_t {0}}, {2, _forcedState.active ? 0.0f : _timeout}, {2, _forcedState.offset.x}, {2, _forcedState.offset.y}, @@ -150,7 +152,7 @@ std::optional MoveToObjectAction::saveFacingState() const { result.parameters = { SavedActionParameter { static_cast(SavedActionParameterType::Object), - SavedObjectReference {_moveTo->id()}}, + SavedObjectReference::fromRuntimeId(_moveTo->id())}, SavedActionParameter { static_cast(SavedActionParameterType::Integer), static_cast(_run ? 1 : 0)}, diff --git a/src/libs/game/action/opendoor.cpp b/src/libs/game/action/opendoor.cpp index 59697b4ae..2c79f54b4 100644 --- a/src/libs/game/action/opendoor.cpp +++ b/src/libs/game/action/opendoor.cpp @@ -48,7 +48,7 @@ void OpenDoorAction::execute(std::shared_ptr self, Object &actor, float return; } - tryUnlockDoorWithKey(*_door, actor, _game.party()); + tryUnlockDoorWithKey(_game, *_door, actor, _game.party()); if (!_door->isLocked()) { _door->open(); diff --git a/src/libs/game/action/playanimation.cpp b/src/libs/game/action/playanimation.cpp index 386545b33..9ebb0cdab 100644 --- a/src/libs/game/action/playanimation.cpp +++ b/src/libs/game/action/playanimation.cpp @@ -79,7 +79,8 @@ std::optional PlayAnimationAction::saveFacingState() const { result.parameters = { SavedActionParameter { static_cast(SavedActionParameterType::Object), - SavedObjectReference {static_cast(_animation)}}, + SavedObjectReference::fromRuntimeId( + static_cast(_animation))}, SavedActionParameter { static_cast(SavedActionParameterType::Float), _speed}, SavedActionParameter { diff --git a/src/libs/game/action/startconversation.cpp b/src/libs/game/action/startconversation.cpp index 706bee004..b5f0556f6 100644 --- a/src/libs/game/action/startconversation.cpp +++ b/src/libs/game/action/startconversation.cpp @@ -147,7 +147,7 @@ std::optional StartConversationAction::saveFacingState() cons result.parameters = { SavedActionParameter { static_cast(SavedActionParameterType::Object), - SavedObjectReference {_objectToConverse->id()}}, + SavedObjectReference::fromRuntimeId(_objectToConverse->id())}, SavedActionParameter { static_cast(SavedActionParameterType::String), _dialogResRef}, diff --git a/src/libs/game/action/unequipitem.cpp b/src/libs/game/action/unequipitem.cpp index 4103a0198..8728a99ad 100644 --- a/src/libs/game/action/unequipitem.cpp +++ b/src/libs/game/action/unequipitem.cpp @@ -17,13 +17,24 @@ #include "reone/game/action/unequipitem.h" +#include "reone/game/game.h" +#include "reone/game/object/creature.h" +#include "reone/game/object/item.h" +#include "reone/game/party.h" + namespace reone { namespace game { void UnequipItemAction::execute(std::shared_ptr self, Object &actor, float dt) { - // TODO: implement - + auto *creature = dyn_cast(&actor); + if (!creature || !_item->isEquipped() || _item->owner() != actor.id()) { + complete(); + return; + } + auto receiver = _game.party().sharedInventoryReceiver( + _game.getObjectById(actor.id())); + if (receiver) creature->moveEquippedItemTo(_item, *receiver); complete(); } diff --git a/src/libs/game/combat.cpp b/src/libs/game/combat.cpp index 8eb772f8e..5aa85011d 100644 --- a/src/libs/game/combat.cpp +++ b/src/libs/game/combat.cpp @@ -119,7 +119,7 @@ static std::shared_ptr findNearestEnemy( bool isEnemy = observer.id() == attacker.id() ? reputes.getIsEnemy(attacker, *candidate) : reputes.getIsEnemy(*candidate, attacker); - if (!isEnemy || observer.perception().seen.count(candidate->id()) == 0) { + if (!isEnemy || !observer.perception().sees(candidate->id())) { continue; } diff --git a/src/libs/game/d20/attributes.cpp b/src/libs/game/d20/attributes.cpp index 6b338df2f..a4b85fee8 100644 --- a/src/libs/game/d20/attributes.cpp +++ b/src/libs/game/d20/attributes.cpp @@ -108,6 +108,38 @@ int CreatureAttributes::getAggregateHitDie() const { return result; } +int CreatureAttributes::getPermanentMaxHitPoints(int baseHitPoints) const { + const int level = getAggregateLevel(); + if (level <= 0) { + return std::max(0, baseHitPoints); + } + + int featHitPointsPerLevel = 0; + if (hasFeat(FeatType::MasterToughness)) { + featHitPointsPerLevel += 2; + } else if (hasFeat(FeatType::Toughness)) { + featHitPointsPerLevel += 1; + } + + if (hasFeat(FeatType::MasterWookieEndurance)) { + featHitPointsPerLevel += 4; + } else if (hasFeat(FeatType::ImprovedWookieEndurance)) { + featHitPointsPerLevel += 3; + } else if (hasFeat(FeatType::WookieEndurance)) { + featHitPointsPerLevel += 2; + } + + int result = std::max( + level, + baseHitPoints + + getAbilityModifier(Ability::Constitution) * level); + result += featHitPointsPerLevel * level; + if (hasFeat(FeatType::WarVeteran)) { + result += 25; + } + return result; +} + int CreatureAttributes::getAggregateLevel() const { int result = 0; for (auto &pair : _classLevels) { diff --git a/src/libs/game/effect.cpp b/src/libs/game/effect.cpp index dcc8e3e40..3580fb998 100644 --- a/src/libs/game/effect.cpp +++ b/src/libs/game/effect.cpp @@ -17,8 +17,13 @@ #include "reone/game/effect.h" +#include + +#include "reone/game/game.h" +#include "reone/game/location.h" #include "reone/game/object.h" #include "reone/resource/gff.h" +#include "reone/script/variable.h" #include "reone/system/logutil.h" namespace reone { @@ -73,8 +78,136 @@ bool Effect::onApply(Object &object) { void Effect::onRemove(Object &object) { } -EffectInstance EffectInstance::fromGff(const resource::Gff &gff) { +EffectInstance Effect::saveFacingInstance() const { + if (!_saveFacingRepresentable) { + throw std::runtime_error( + "live effect has no representable retail CGameEffect value"); + } + EffectInstance result; + result.retailType = static_cast(_type); + // Retail VM-created effects remain engine values until ApplyEffectToObject + // supplies the duration bits. Bit 3 identifies that un-applied form. + result.subType = 0x8; + result.creatorId = kSavedEffectInvalidObjectId; + result.integerParameters = _saveFacingIntegers; + result.floatParameters = _saveFacingFloats; + result.stringParameters = _saveFacingStrings; + if (auto creator = _saveFacingCreator.resolve()) { + result.creatorId = creator->id(); + result.creator = creator; + } + for (size_t index = 0; index < _saveFacingObjects.size(); ++index) { + if (auto object = _saveFacingObjects[index].resolve()) { + result.objectParameters[index] = object->id(); + result.objectParameterObjects[index] = object; + } + } + return result; +} + +void Effect::setSaveFacingCreator(const std::shared_ptr &creator) { + _saveFacingCreator = creator; +} + +void Effect::captureSaveFacingScriptArguments( + const std::vector &arguments, + const Game &game) { + if (_type == EffectType::LinkEffects) { + // Retail SaveGameEffect writes only the flat type-40 CGameEffect and + // does not serialize m_pLinkLeft/m_pLinkRight. Preserve that exact, + // representable-but-inert value rather than inventing child fields. + return; + } + + size_t integerIndex = 0; + size_t floatIndex = 0; + size_t stringIndex = 0; + size_t objectIndex = 0; + for (const auto &argument : arguments) { + switch (argument.type) { + case script::VariableType::Int: + if (_type == EffectType::Visual && integerIndex == 1) { + setSaveFacingInteger(2, argument.intValue); + } else if (_type != EffectType::LightsaberThrow) { + setSaveFacingInteger(integerIndex, argument.intValue); + } + ++integerIndex; + break; + case script::VariableType::Float: + if (floatIndex < _saveFacingFloats.size()) { + setSaveFacingFloat(floatIndex++, argument.floatValue); + } + break; + case script::VariableType::String: + if (stringIndex < _saveFacingStrings.size()) { + setSaveFacingString(stringIndex++, argument.strValue); + } + break; + case script::VariableType::Object: + if (objectIndex < _saveFacingObjects.size()) { + setSaveFacingObject( + objectIndex++, game.getObjectById(argument.objectId)); + } + break; + case script::VariableType::Location: { + auto location = std::dynamic_pointer_cast( + argument.engineType); + if (!location) { + _saveFacingRepresentable = false; + break; + } + const auto &position = location->position(); + for (float value : {position.x, position.y, position.z}) { + if (floatIndex < _saveFacingFloats.size()) { + setSaveFacingFloat(floatIndex++, value); + } + } + break; + } + case script::VariableType::Effect: + _saveFacingRepresentable = false; + break; + default: + break; + } + } +} + +void Effect::setSaveFacingInteger(size_t index, int32_t value) { + if (_saveFacingIntegers.size() <= index) { + _saveFacingIntegers.resize(index + 1); + } + _saveFacingIntegers[index] = value; +} + +void Effect::setSaveFacingFloat(size_t index, float value) { + if (index >= _saveFacingFloats.size()) { + throw std::out_of_range("effect float parameter index"); + } + _saveFacingFloats[index] = value; +} + +void Effect::setSaveFacingString(size_t index, std::string value) { + if (index >= _saveFacingStrings.size()) { + throw std::out_of_range("effect string parameter index"); + } + _saveFacingStrings[index] = std::move(value); +} + +void Effect::setSaveFacingObject( + size_t index, + const std::shared_ptr &object) { + if (index >= _saveFacingObjects.size()) { + throw std::out_of_range("effect object parameter index"); + } + _saveFacingObjects[index] = object; +} + +EffectInstance EffectInstance::fromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { EffectInstance result; + result.serializedReferenceContext = identityContext; result.id = gff.getUint64("Id"); result.retailType = static_cast(gff.getUint("Type")); result.subType = static_cast(gff.getUint("SubType")); @@ -131,15 +264,62 @@ DurationType EffectInstance::durationType() const { } } +std::shared_ptr EffectInstance::boundCreator() const { + return creator.resolve(); +} + +std::shared_ptr EffectInstance::boundObjectParameter( + size_t index) const { + return index < objectParameterObjects.size() + ? objectParameterObjects[index].resolve() + : nullptr; +} + bool EffectInstance::bindCreator(const std::shared_ptr &object) { creator.reset(); - if (!object || object->id() != creatorId) { + if (!object) { return false; } creator = object; return true; } +bool EffectInstance::bindObjectParameter( + size_t index, const std::shared_ptr &object) { + if (index >= objectParameterObjects.size()) { + return false; + } + objectParameterObjects[index].reset(); + if (!object) { + return false; + } + objectParameterObjects[index] = object; + return true; +} + +void EffectInstance::retireAreaRuntimeBindings( + const std::set &retainedObjects) { + auto retain = [&retainedObjects]( + RuntimeObjectRef &binding, + uint32_t &identity) { + auto object = binding.resolve(); + if (!object || retainedObjects.count(object.get()) == 0) { + binding.reset(); + identity = kSavedEffectInvalidObjectId; + return; + } + identity = object->id(); + }; + + retain(creator, creatorId); + for (size_t index = 0; index < objectParameters.size(); ++index) { + retain(objectParameterObjects[index], objectParameters[index]); + } + serializedReferenceContext.reset(); + _savedGraph.reset(); + _runtimeSession.reset(); +} + SavedEffectValue::SavedEffectValue(EffectInstance instance) : Effect(static_cast(instance.retailType)), _instance(std::move(instance)) { diff --git a/src/libs/game/effect/linkeffects.cpp b/src/libs/game/effect/linkeffects.cpp index 02d3c20bc..c4cb82d7b 100644 --- a/src/libs/game/effect/linkeffects.cpp +++ b/src/libs/game/effect/linkeffects.cpp @@ -25,6 +25,12 @@ void LinkEffectsEffect::applyTo(Object &object) { // TODO: implement } +void LinkEffectsEffect::retireAreaRuntime( + const std::set &retainedObjects) { + if (_childEffect) _childEffect->retireAreaRuntime(retainedObjects); + if (_parentEffect) _parentEffect->retireAreaRuntime(retainedObjects); +} + } // namespace game } // namespace reone diff --git a/src/libs/game/effect/visual.cpp b/src/libs/game/effect/visual.cpp index b546cce13..dc15531b1 100644 --- a/src/libs/game/effect/visual.cpp +++ b/src/libs/game/effect/visual.cpp @@ -35,12 +35,20 @@ VisualEffect::VisualEffect(int visualEffectId, bool missEffect, ServicesView &se _missEffect(missEffect), _desc(services.game.visualEffects.get(visualEffectId).value_or(nullptr)), _services(services) { + setSaveFacingInteger(0, visualEffectId); + setSaveFacingInteger(2, missEffect ? 1 : 0); } VisualEffect::~VisualEffect() { + retireAreaRuntime({}); +} + +void VisualEffect::retireAreaRuntime( + const std::set &) { if (_node) { scene::ISceneGraph &graph = _node->graph(); graph.removeRoot(*_node); + _node.reset(); } } diff --git a/src/libs/game/equipmentrules.cpp b/src/libs/game/equipmentrules.cpp index c5fe7f88f..3405e21b3 100644 --- a/src/libs/game/equipmentrules.cpp +++ b/src/libs/game/equipmentrules.cpp @@ -19,8 +19,10 @@ #include "reone/game/game.h" #include "reone/game/object.h" +#include "reone/game/object/area.h" #include "reone/game/object/creature.h" #include "reone/game/object/item.h" +#include "reone/game/object/module.h" #include "reone/game/types.h" namespace reone { @@ -225,12 +227,89 @@ std::shared_ptr takeEquipmentCandidate( return item; } - auto split = game.newItem(); - split->clone(*item); + auto split = game.newItemClone(*item); split->setStackSize(1); return split; } +static std::shared_ptr activeAreaOwningItem( + Game &game, + const std::shared_ptr &item) { + if (!item) return nullptr; + auto module = game.module(); + auto area = module ? module->area() : nullptr; + if (!area) return nullptr; + auto owned = std::find_if( + area->objects().begin(), area->objects().end(), + [&item](const auto &candidate) { + return candidate.get() == item.get(); + }); + return owned == area->objects().end() ? nullptr : area; +} + +bool isActiveAreaOwnedItem( + Game &game, + const std::shared_ptr &item) { + return static_cast(activeAreaOwningItem(game, item)); +} + +bool releaseAreaOwnedItem( + Game &game, + const std::shared_ptr &item) { + if (!item || !item->isRuntimeLive() || item->isPresentationOnly() || + item->isEquipped() || + (item->owner() != 0 && item->owner() != script::kObjectInvalid)) { + return false; + } + auto area = activeAreaOwningItem(game, item); + return area && area->releaseObject(item); +} + +bool transferItemTo( + Game &game, + const std::shared_ptr &item, + Object &receiver) { + if (!item || (!item->isRuntimeLive() && !item->isPresentationOnly()) || + (!receiver.isRuntimeLive() && !receiver.isPresentationOnly()) || + (item->isPresentationOnly() != receiver.isPresentationOnly()) || + item.get() == &receiver) { + return false; + } + + uint32_t ownerId = item->owner(); + if (ownerId == receiver.id() && !item->isEquipped()) return true; + + if (ownerId == 0 || ownerId == script::kObjectInvalid) { + // owner() describes nested inventory/equipment ownership; it does not + // encode Area residency. Only an exact active-Area ownership edge makes + // an ownerless-numbered Item transferable. Everything else fails + // closed rather than treating owner()==0 as freely claimable. + if (item->isPresentationOnly() || item->isEquipped() || + std::find(receiver.items().begin(), receiver.items().end(), item) != + receiver.items().end()) { + return false; + } + if (!releaseAreaOwnedItem(game, item)) { + return false; + } + receiver.addItem(item); + return true; + } + + auto owner = game.getObjectById(ownerId); + if (!owner) return false; + + if (auto creature = std::dynamic_pointer_cast(owner); + creature && item->isEquipped()) { + if (!creature->takeEquippedItem(item)) return false; + } else if (!owner->removeItemStack(item)) { + return false; + } + + receiver.addItem(item); + return true; +} + } // namespace game } // namespace reone diff --git a/src/libs/game/event.cpp b/src/libs/game/event.cpp new file mode 100644 index 000000000..dcbc3bb4f --- /dev/null +++ b/src/libs/game/event.cpp @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2026 The reone project contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "reone/game/event.h" + +#include "reone/game/object.h" +#include "reone/script/types.h" + +namespace reone { + +namespace game { + +Event::Event( + int number, + std::vector integers, + std::vector floats, + std::vector strings, + std::vector> objects) : + _number(number), + _integers(std::move(integers)), + _floats(std::move(floats)), + _strings(std::move(strings)) { + _objects.reserve(objects.size()); + for (const auto &object : objects) { + _objects.emplace_back(object); + } +} + +std::vector Event::objects() const { + std::vector result; + result.reserve(_objects.size()); + for (const auto &reference : _objects) { + auto object = reference.resolve(); + result.push_back(object ? object->id() : script::kObjectInvalid); + } + return result; +} + +} // namespace game + +} // namespace reone diff --git a/src/libs/game/game.cpp b/src/libs/game/game.cpp index ba854e5d3..ae8cbafeb 100644 --- a/src/libs/game/game.cpp +++ b/src/libs/game/game.cpp @@ -68,6 +68,7 @@ #include "reone/resource/format/gffreader.h" #include "reone/resource/format/gffwriter.h" #include "reone/resource/parser/gff/gvt.h" +#include "reone/resource/parser/gff/git.h" #include "reone/resource/parser/gff/nfo.h" #include "reone/resource/provider/2das.h" #include "reone/resource/provider/audioclips.h" @@ -120,7 +121,9 @@ ModuleLoadContext resolveModuleLoadContext( bool savedModuleSnapshot) { if (initialSaveRestore) { - return ModuleLoadContext::InitialSaveRestore; + return savedModuleSnapshot + ? ModuleLoadContext::InitialSaveRestore + : ModuleLoadContext::InitialTemplateRestore; } return savedModuleSnapshot ? ModuleLoadContext::SavedModuleTransition @@ -128,16 +131,66 @@ ModuleLoadContext resolveModuleLoadContext( } bool restoresSavedWorld(ModuleLoadContext context) { - return context != ModuleLoadContext::FreshModule; + return context == ModuleLoadContext::InitialSaveRestore || + context == ModuleLoadContext::SavedModuleTransition; } bool restoresSavedSession(ModuleLoadContext context) { + return context == ModuleLoadContext::InitialTemplateRestore || + context == ModuleLoadContext::InitialSaveRestore; +} + +bool preservesSavedPlacement(ModuleLoadContext context) { return context == ModuleLoadContext::InitialSaveRestore; } bool Game::bindEffectCreator(EffectInstance &effect) const { - auto it = _objectById.find(effect.creatorId); - return effect.bindCreator(it == _objectById.end() ? nullptr : it->second); + if (effect._runtimeSession && + *effect._runtimeSession != _runtimeSessionGeneration) { + effect.creator.reset(); + for (auto &object : effect.objectParameterObjects) { + object.reset(); + } + return false; + } + const auto &serializedContext = effect.serializedReferenceContext; + const bool moduleGraphReferences = + serializedContext && + serializedContext->domain == SerializedIdentityDomain::ModuleGraph; + if (moduleGraphReferences && effect._savedGraph && + *effect._savedGraph != _savedGraphGeneration) { + effect.creator.reset(); + for (auto &object : effect.objectParameterObjects) { + object.reset(); + } + return false; + } + if (!effect._runtimeSession) { + effect._runtimeSession = _runtimeSessionGeneration; + } + if (moduleGraphReferences && !effect._savedGraph) { + effect._savedGraph = _savedGraphGeneration; + } + auto resolve = [this, &serializedContext](uint32_t id) { + return serializedContext + ? resolveSerializedObjectReference(id, *serializedContext) + : getObjectById(id); + }; + bool allBound = true; + if (effect.creatorId != kSavedEffectInvalidObjectId && + effect.creatorId != kSavedRuntimeInvalidObjectId) { + allBound = effect.bindCreator(resolve(effect.creatorId)); + } + for (size_t index = 0; index < effect.objectParameters.size(); ++index) { + uint32_t id = effect.objectParameters[index]; + if (id == kSavedEffectInvalidObjectId || + id == kSavedRuntimeInvalidObjectId) { + continue; + } + allBound = effect.bindObjectParameter(index, resolve(id)) && + allBound; + } + return allBound; } bool Game::bindSavedObjectReference(SavedObjectReference &reference) const { @@ -152,16 +205,68 @@ bool Game::bindSavedObjectReference(SavedObjectReference &reference) const { if (!reference._runtimeSession) { reference._runtimeSession = _runtimeSessionGeneration; } + const auto &serializedContext = reference.serializedIdentityContext(); + const bool moduleGraphReference = + serializedContext && + serializedContext->domain == SerializedIdentityDomain::ModuleGraph; + if (moduleGraphReference && reference._savedGraph && + *reference._savedGraph != _savedGraphGeneration) { + reference._object.reset(); + return false; + } + if (moduleGraphReference && !reference._savedGraph) { + reference._savedGraph = _savedGraphGeneration; + } - auto it = _objectById.find(reference.id); - if (it == _objectById.end()) { + auto object = serializedContext + ? resolveSerializedObjectReference( + reference.id, *serializedContext) + : getObjectById(reference.id); + if (!object) { reference._object.reset(); return false; } - reference._object = it->second; + reference._object = object; return true; } +std::shared_ptr Game::resolveSerializedObjectReference( + uint32_t id, + const SerializedIdentityContext &identityContext) const { + switch (identityContext.domain) { + case SerializedIdentityDomain::ModuleGraph: + if (!_reservedSavedIdentityNamespace || + *_reservedSavedIdentityNamespace != identityContext.identityNamespace) { + return nullptr; + } + return getObjectBySavedId(id); + case SerializedIdentityDomain::Template: + // ObjectId-shaped fields in blueprints do not name instances in any + // live serialized graph. Treating them as module references would + // recreate the cross-domain equality assumption A1 removed. + return nullptr; + case SerializedIdentityDomain::DetachedRecord: { + std::shared_ptr result; + for (const auto &[_, candidate] : _objectById) { + auto identity = candidate->serializedObjectIdentity(); + if (!identity || !(identity->context == identityContext) || + identity->id != id) { + continue; + } + if (result && result.get() != candidate.get()) { + throw ValidationException( + "Ambiguous detached-record object reference " + + identityContext.identityNamespace + ":" + + std::to_string(id)); + } + result = candidate; + } + return result; + } + } + return nullptr; +} + static constexpr char kDeveloperOverlayToggleHelp[] = "Ctrl+Shift+D"; static constexpr char kDeveloperTriggerToggleHelp[] = "Ctrl+Shift+T"; static constexpr char kDeveloperActorToggleHelp[] = "Ctrl+Shift+A"; @@ -912,7 +1017,124 @@ bool Game::handleMouseButtonUp(const input::MouseButtonEvent &event) { return false; } -bool Game::loadModule(const std::string &name, std::string entry, bool initialSaveRestore) { +Game::PreparedDestinationModule Game::prepareDestinationModule( + const std::string &name, + bool initialSaveRestore, + std::shared_ptr workingState) { + PreparedDestinationModule prepared; + prepared.resources = _services.resource.director.prepareModuleLoad( + name, std::move(workingState)); + if (!prepared.resources || !prepared.resources->structurallyValidated()) { + throw ValidationException("Destination module was not structurally validated"); + } + prepared.ifo = prepared.resources->moduleIfo(); + prepared.are = prepared.resources->areaAre(); + prepared.git = prepared.resources->areaGit(); + prepared.name = prepared.resources->moduleName(); + // GIT.UseTemplates is the retail world-representation discriminator. + // Mod_IsSaveGame describes the surrounding IFO but cannot turn a + // template GIT into an authoritative instance graph (or vice versa). + const bool authoritativeSavedWorld = + prepared.git && + resource::generated::parseGIT(*prepared.git).UseTemplates == 0; + prepared.context = resolveModuleLoadContext( + initialSaveRestore, authoritativeSavedWorld); + validatePreparedDestination(prepared); + return prepared; +} + +void Game::validatePreparedDestination( + const PreparedDestinationModule &prepared) const { + if (!prepared.ifo || !prepared.are || !prepared.git) { + throw ResourceNotFoundException( + "Prepared destination is missing IFO/ARE/GIT state"); + } + auto ifo = resource::generated::parseIFO(*prepared.ifo); + if (ifo.Mod_Entry_Area.empty()) { + throw ValidationException("Mod_Entry_Area must not be empty"); + } + (void)resource::generated::parseARE(*prepared.are); + (void)resource::generated::parseGIT(*prepared.git); + + // Initial disk restoration may legitimately fall back to an installed + // module when the save has no archived current-module graph (#325). Only a + // non-template GIT owns the authoritative ModuleGraph namespace; + // template-local numbers are not saved identities. + if (!restoresSavedWorld(prepared.context)) { + return; + } + + const auto identityContext = SerializedIdentityContext::moduleGraph( + prepared.resources->moduleName()); + std::map claims; + auto validateClaims = [&](const resource::Gff &record, + SerializedGraphRoot root, + const std::string &prefix) { + for (auto claim : collectSerializedObjectIdClaims( + record, identityContext, root)) { + if (claim.id == kSavedRuntimeModuleObjectId || + claim.id == std::numeric_limits::max()) { + throw ValidationException( + "Invalid authoritative saved ObjectId " + + std::to_string(claim.id) + " at " + prefix + claim.path); + } + auto [found, inserted] = claims.emplace( + claim.id, prefix + claim.path); + if (!inserted) { + throw ValidationException( + "Duplicate authoritative saved ObjectId " + + std::to_string(claim.id) + " at " + found->second + + " and " + prefix + claim.path); + } + } + }; + validateClaims( + *prepared.ifo, SerializedGraphRoot::ModuleIfo, "module/"); + validateClaims( + *prepared.git, SerializedGraphRoot::AreaGit, "area/"); + + uint32_t nextObjectId = kFirstRuntimeObjectId; + if (prepared.ifo->readDword(nextObjectId, "Mod_NextObjId0") && + nextObjectId != 0 && nextObjectId < kFirstRuntimeObjectId) { + throw ValidationException("Invalid Mod_NextObjId0"); + } + uint64_t nextEffectId = 0; + if (prepared.ifo->readDword64(nextEffectId, "Mod_Effect_NxtId") && + (nextEffectId == kUnassignedEffectId || + nextEffectId == std::numeric_limits::max())) { + throw ValidationException("Invalid Mod_Effect_NxtId"); + } +} + +bool Game::loadModule( + const std::string &name, + std::string entry, + bool initialSaveRestore) { + info("Preparing module '" + name + "'"); + PreparedDestinationModule prepared; + try { + prepared = prepareDestinationModule( + name, + initialSaveRestore, + _services.resource.director.committedSaveWorkingState()); + } catch (const std::exception &e) { + error("Failed preparing module '" + name + "': " + e.what()); + return false; + } + return loadPreparedModule( + std::move(prepared), + std::move(entry), + initialSaveRestore, + /*resourcesCommitted=*/false); +} + +bool Game::loadPreparedModule( + PreparedDestinationModule prepared, + std::string entry, + bool initialSaveRestore, + bool resourcesCommitted, + std::shared_ptr sourceWorkingState) { + const std::string name = prepared.name; info("Loading module '" + name + "'"); _transitionInProgress = true; struct TransitionGuard { @@ -929,46 +1151,21 @@ bool Game::loadModule(const std::string &name, std::string entry, bool initialSa ~LoadFromSaveGuard() { value = false; } } loadFromSaveGuard {_loadingFromSaveGame}; - // A module transition is a technical Pazaak abort. It must not manufacture - // a result or invoke the pending continuation. - abortPazaak(); - - // Tear down an active race before the current area (and its camera/scene) - // is unloaded, so no dangling references survive the transition. - if (_swoopRace.isActive()) { - _swoopRace.stop(); - _cameraType = _savedCameraType; - } - if (_turret.isActive()) { - _turret.stop(); - _cameraType = _savedCameraType; - } - // A direct module load (e.g. warp) while a lifecycle session is pending - // means the player navigated away; abandon the pending return. - // (Lifecycle-managed loads clear the session beforehand, so this only fires - // on external loads.) - if (_swoopLifecycle.active) { - _swoopLifecycle = MinigameLifecycle(); - } - if (_turretLifecycle.active) { - _turretLifecycle = MinigameLifecycle(); - } - // Likewise a scheduled turret session is only good for the module it named: - // any other load means the transition was superseded, and keeping the - // request would block startturretgame until the game was reset. - if (_pendingTurret.active && !boost::iequals(_pendingTurret.targetModule, name)) { - debug(str(boost::format("turret: scheduled session for '%s' dropped, loading '%s' instead") - % _pendingTurret.targetModule % name)); - _pendingTurret = PendingTurretRequest(); - } - - if (_screen == Screen::Conversation && _conversation) { - _conversation->cleanupForModuleTransition(); - } + struct PartyTransitionRuntimeState { + struct DelayedEvent { + SavedEventRecord event; + bool referencesBound {false}; + }; + RuntimeObjectRef creature; + SavedActionQueue actions; + std::vector actionReferencesBound; + std::vector delayedEvents; + }; + std::vector partyTransitionState; - // Exit scripts are part of the source module's last observable state. - // Freeze that state before party/object teardown or destination mounting. - // A capture failure aborts with the source runtime graph still alive. + // Exit scripts are part of the source module's last observable state. The + // resulting working state remains a candidate until the resource/runtime + // commit below; a capture failure leaves the source graph authoritative. if (_module) { try { _module->area()->runOnExitScript(); @@ -976,19 +1173,159 @@ bool Game::loadModule(const std::string &name, std::string entry, bool initialSa error("Source module exit script failed: " + std::string(e.what())); return false; } - if (!storeCurrentModuleForTransition()) { + sourceWorkingState = prepareCurrentModuleWorkingState(); + if (!sourceWorkingState) { return false; } + + // Retail serializes resident Party members before destroying their + // source representations. Reone retains the Creature storage, but the + // live action/timer objects still belong to the outgoing Area. Capture + // their existing save-facing forms now and reconstruct them only after + // the destination is authoritative. + try { + const auto &residentCreatures = + _module->area()->getObjectsByType(ObjectType::Creature); + std::set captured; + for (const auto &object : _party.runtimeObjects()) { + auto creature = std::dynamic_pointer_cast(object); + if (!creature || !captured.insert(creature.get()).second || + std::find( + residentCreatures.begin(), residentCreatures.end(), creature) == + residentCreatures.end()) { + continue; + } + + PartyTransitionRuntimeState state; + state.creature = creature; + state.actions.actions = creature->saveActionSnapshot(); + for (auto &action : state.actions.actions) { + // Bind while the source registry is authoritative. Copies of + // these references retain exact C4 incarnation handles, never + // a numeric promise that the destination may reinterpret. + state.actionReferencesBound.push_back( + action.bindObjectReferences(*this)); + } + + for (size_t index = 0; index < creature->_delayed.size(); ++index) { + const auto &delayed = creature->_delayed[index]; + if (!delayed.action || delayed.action->isCompleted() || + delayed.action->isCancelled()) { + continue; + } + auto savedAction = delayed.action->saveFacingState(); + if (!savedAction || savedAction->actionId != 37 || + savedAction->parameters.size() != 1) { + throw ValidationException( + "Party delayed action has no retail timed-event representation"); + } + auto situation = std::get_if( + &savedAction->parameters.front().payload); + if (!situation) { + throw ValidationException( + "Party delayed DoCommand action lacks a script situation"); + } + + const double remainingSeconds = delayed.timer + ? std::max( + 0.0f, + delayed.timer->remaining()) + : 0.0f; + const uint64_t absolute = _worldTimeMilliseconds + + static_cast( + std::floor(remainingSeconds * 1000.0)); + const uint64_t millisecondsPerDay = millisecondsPerWorldDay(); + + SavedEventRecord event; + event.day = static_cast( + absolute / millisecondsPerDay); + event.time = static_cast( + absolute % millisecondsPerDay); + event.object = + SavedObjectReference::fromRuntimeId(creature->id()); + event.caller = + SavedObjectReference::fromRuntimeId(creature->id()); + event.eventId = static_cast(SavedEventType::Timed); + event.payload = *situation; + const bool referencesBound = + event.bindObjectReferences(*this); + state.delayedEvents.push_back({ + std::move(event), referencesBound}); + } + partyTransitionState.push_back(std::move(state)); + } + } catch (const std::exception &e) { + error( + "Unable to snapshot Party transition state: " + + std::string(e.what())); + return false; + } + + // Re-entering the same module must inspect the snapshot just captured, + // not the previously committed visit. Other destinations are + // unaffected because the candidate changed only the source archive. + if (boost::iequals(_module->name(), name)) { + try { + prepared = prepareDestinationModule( + name, initialSaveRestore, sourceWorkingState); + } catch (const std::exception &e) { + error("Failed preparing snapshotted module '" + name + + "': " + e.what()); + return false; + } + } } bool loaded = false; - withLoadingScreen("load_" + name, [this, &name, &entry, initialSaveRestore, &loaded]() { - loadInGameMenus(); - + const auto sourceScreen = _screen; + bool commitStarted = false; + try { + withLoadingScreen("load_" + name, [this, + &prepared, + &name, + &entry, + initialSaveRestore, + resourcesCommitted, + &sourceWorkingState, + &partyTransitionState, + &commitStarted, + &loaded]() { try { + commitStarted = true; + // Destination structure and the source snapshot are both viable. + // Technical teardown belongs on the commit side of that boundary: + // a rejected destination or failed snapshot must not abort an + // otherwise valid gameplay session. + abortPazaak(); + if (_swoopRace.isActive()) { + _swoopRace.stop(); + _cameraType = _savedCameraType; + } + if (_turret.isActive()) { + _turret.stop(); + _cameraType = _savedCameraType; + } + if (_swoopLifecycle.active) { + _swoopLifecycle = MinigameLifecycle(); + } + if (_turretLifecycle.active) { + _turretLifecycle = MinigameLifecycle(); + } + if (_pendingTurret.active && + !boost::iequals(_pendingTurret.targetModule, name)) { + debug(str(boost::format( + "turret: scheduled session for '%s' dropped, loading '%s' instead") + % _pendingTurret.targetModule % name)); + _pendingTurret = PendingTurretRequest(); + } + if (_screen == Screen::Conversation && _conversation) { + _conversation->cleanupForModuleTransition(); + } + if (_module) { - _module->area()->unloadParty(); + // The source snapshot is already frozen. Module retirement + // now owns the full Area-departure boundary itself. retireActiveModuleRuntime(); } @@ -1001,7 +1338,20 @@ bool Game::loadModule(const std::string &name, std::string entry, bool initialSa _hud->resetStatusSummaryPresentation(); } - _services.resource.director.onModuleLoad(name); + if (!resourcesCommitted) { + _services.resource.director.commitModuleLoad( + std::move(prepared.resources)); + } + + // Resource publication is the only fallible destination commit. + // Adopt the frozen source snapshot only after it succeeds, so a + // destination that never publishes does not alter working state. + if (sourceWorkingState) { + _services.resource.director.adoptSaveWorkingState( + std::move(sourceWorkingState)); + } + + loadInGameMenus(); if (_loadScreen) { _loadScreen->setProgress(50); @@ -1010,42 +1360,97 @@ bool Game::loadModule(const std::string &name, std::string entry, bool initialSa _services.scene.graphs.get(kSceneMain).clear(); - std::shared_ptr ifo(_services.resource.gffs.get("module", ResType::Ifo)); - if (!ifo) { - throw ResourceNotFoundException("Module IFO not found"); - } - ModuleLoadContext context = resolveModuleLoadContext( - initialSaveRestore, - ifo->getBool("Mod_IsSaveGame")); + const auto &ifo = prepared.ifo; + ModuleLoadContext context = prepared.context; bool restoringSavedWorld = restoresSavedWorld(context); - bool restoringSavedSession = restoresSavedSession(context); + const auto identityContext = + restoringSavedWorld + ? SerializedIdentityContext::moduleGraph(name) + : SerializedIdentityContext::templateResource(name); + const std::string entryArea = ifo->getString("Mod_Entry_Area"); // The module itself needs a transient runtime identity even though // it is not serialized. Keep that allocation clear of identities // explicitly owned by the saved IFO graph (notably the Area). if (restoringSavedWorld) { - reserveSavedObjectIds(*ifo); + reserveSavedObjectIds( + *ifo, + SerializedIdentityContext::moduleGraph(name), + SerializedGraphRoot::ModuleIfo); } - _module = restoringSavedWorld ? newSavedModule() : newModule(); - _module->load(name, *ifo, restoringSavedWorld); + std::shared_ptr destinationModule; + std::vector> noObsolete; + replaceRuntimeObjectGraph( + noObsolete, + [&]() { + destinationModule = restoringSavedWorld + ? newSavedModule() + : newModule(); + if (restoringSavedWorld) { + registerSavedModuleReferenceTarget( + destinationModule, + SerializedIdentityContext::moduleGraph(name)); + } + }, + [&]() noexcept { + _module = std::move(destinationModule); + }); + _module->load( + name, + *ifo, + *prepared.are, + *prepared.git, + restoringSavedWorld); _loadedModules.insert(std::make_pair(name, _module)); + // Structural construction is complete and the destination module + // is now the authoritative script caller. Authored gameplay begins + // here; failures from this point are terminal rather than rolled + // back as if arbitrary NWScript mutation were transactional. + if (!restoringSavedWorld) { + _module->runSpawnScripts(); + } + if (_party.isEmpty()) { loadDefaultParty(); } + // Retail makes restored effects, actions and events visible before + // authored OnLoad/OnEnter scripts inspect or mutate them. Binding + // remains explicit and graph-local; only publication moves ahead + // of the entry hooks. + bindSavedRuntimeState(); + + for (auto &state : partyTransitionState) { + auto creature = state.creature.resolve(); + if (!creature) continue; + + // Source references were resolved while their graph was still + // authoritative. Publish those exact C4 incarnations without + // looking their serialized numbers up in the destination. + creature->_savedEffects.clear(); + creature->_savedActionQueue = std::move(state.actions); + creature->_savedEffectReferencesBound.clear(); + creature->_savedActionReferencesBound = + std::move(state.actionReferencesBound); + creature->_savedRuntimeParsed = true; + creature->_savedRuntimePublished = false; + creature->_loadedSaveActionSlots.clear(); + + for (auto &delayed : state.delayedEvents) { + _module->enqueueBoundSaveEvent( + std::move(delayed.event), delayed.referencesBound); + } + } + publishSavedRuntimeState(); + // Whether the world came from persisted state decides what gets // restored, not whether the module's authored entry hook runs. // Content relies on that hook every time it is entered, including // when revisiting a module whose world state is restored. _module->runOnLoadScript(); - _module->loadParty(entry, restoringSavedSession); - - if (restoringSavedWorld) { - bindSavedRuntimeState(); - publishSavedRuntimeState(); - } + _module->loadParty(entry, preservesSavedPlacement(context)); info("Module '" + name + "' loaded successfully"); @@ -1061,19 +1466,34 @@ bool Game::loadModule(const std::string &name, std::string entry, bool initialSa loaded = true; } catch (const std::exception &e) { error("Failed loading module '" + name + "': " + std::string(e.what())); - if (initialSaveRestore) { - // Restoring a save has already retired the session that was - // running, so there is nothing to fall back to. Retiring alone - // leaves Screen::None, which renders as a black window over a - // still-updating engine; send the player somewhere deliberate. - retireRuntimeSession(); + // Source retirement is the commit boundary for an ordinary + // transition, just as resetGame is for a disk load. Authored or + // otherwise post-commit failure cannot resurrect the old world; + // retire the partial destination and land somewhere deliberate. + retireToMainMenu(); + } + }); + } catch (const std::exception &e) { + error("Module load escaped its failure boundary for '" + name + + "': " + e.what()); + if (!commitStarted) { + // Loading-screen preparation precedes runtime/resource commit. Its + // presentation work is reversible without a parallel scene: the + // authoritative source world remains the current session. + _screen = sourceScreen; + } else { + // A terminal-path dependency failed while retiring the published + // destination. Do not expose a blank or resurrected old world. + try { retireToMainMenu(); - } else { - retireActiveModuleRuntime(); - _screen = Screen::None; + } catch (const std::exception &terminalError) { + error("Failed entering Main Menu after load failure: " + + std::string(terminalError.what())); + _screen = Screen::MainMenu; } } - }); + return false; + } // However long that took, none of it was time the game world lived // through. Whoever drives the frame clock has to start a new epoch before @@ -1087,46 +1507,46 @@ void Game::retireActiveModuleRuntime() { _lastRenderedSceneOutput = nullptr; _runtimeSessionPlayable = false; - std::set sessionObjectIds; - std::function &)> preserve; - preserve = [&](const std::shared_ptr &object) { - if (!object || !sessionObjectIds.insert(object->id()).second) { - return; - } - for (const auto &item : object->items()) { - preserve(item); - } - if (object->type() != ObjectType::Creature) { - return; - } - auto creature = std::static_pointer_cast(object); - for (const auto &[_, item] : creature->equipment()) { - preserve(item); - } - }; + // This is also the failed-destination cleanup boundary. If construction + // attached only part of the retained party, Area retirement inspects + // actual residency and safely ignores every not-yet-attached object. + retireActiveAreaRuntime(); - preserve(_party.player()); - preserve(_party.actualPlayer()); - for (const auto &member : _party.members()) { - preserve(member.creature); - } - for (size_t npc = 0; npc < Party::kMaxNpcCount; ++npc) { - preserve(_party.getAvailableMember(static_cast(npc))); - } - for (size_t puppet = 0; puppet < Party::kMaxPuppetCount; ++puppet) { - preserve(_party.getAvailablePuppet(static_cast(puppet))); + std::set sessionObjectIds; + for (const auto &object : _party.runtimeObjects()) { + if (object) sessionObjectIds.insert(object->id()); } _combat.reset(); _module.reset(); _loadedModules.clear(); - for (auto it = _objectById.begin(); it != _objectById.end();) { - if (sessionObjectIds.count(it->first) != 0) { - ++it; - } else { - it = _objectById.erase(it); + std::vector> retiredObjects; + for (const auto &[id, object] : _objectById) { + if (sessionObjectIds.count(id) == 0) { + retiredObjects.push_back(object); } } + for (const auto &object : retiredObjects) { + destroyRuntimeObjectGraph(object); + } + retireSavedObjectGraph(); +} + +void Game::retireActiveAreaRuntime() { + auto module = _module; + auto area = module ? module->area() : nullptr; + if (area) { + area->retirePartyAreaRuntime(); + } +} + +void Game::retireSavedObjectGraph() { + ++_savedGraphGeneration; + _reservedSavedObjectIds.clear(); + _reservedSavedIdentityNamespace.reset(); + _reservedSavedObjectIdClaims.clear(); + _objectBySavedId.clear(); + _savedIdByObject.clear(); } void Game::retireRuntimeSession() { @@ -1200,6 +1620,11 @@ void Game::retireRuntimeSession() { _map->retireRuntimeSession(); } + // Full-session teardown can follow a partially failed destination load. + // Retire retained Area residency before Party bindings or the owning + // Module/Area/Pathfinder disappear. A prior failure cleanup is harmless. + retireActiveAreaRuntime(); + _combat.reset(); _party.retireRuntimeSession(); @@ -1207,8 +1632,14 @@ void Game::retireRuntimeSession() { _module.reset(); _loadedModules.clear(); - _objectById.clear(); - _reservedSavedObjectIds.clear(); + // Storage retained by actions, effects or presentation holders remains + // allocated, but every exact runtime incarnation becomes semantically dead + // before the registry is emptied and numeric IDs may restart. + while (!_objectById.empty()) { + unregisterRuntimeObject(_objectById.begin()->second); + } + retireSavedObjectGraph(); + _publishedRuntimeObjectIds.clear(); _nextObjectId = kFirstRuntimeObjectId; _effectIds.reset(); _worldTimeMilliseconds = 0; @@ -1265,23 +1696,47 @@ Game::PreparedSaveLoad Game::prepareSaveLoad(const resource::SaveSlotDescriptor if (!prepared.globalVars) { throw ResourceNotFoundException("globalvars.res not found"); } + (void)resource::parseGVT(*prepared.globalVars); - // The remaining records are optional to the loader. Decode what the slot - // carries so restoration does not read it again, but do not invent a - // requirement: a module IFO absent from the archive still resolves from the - // module itself once mounted, and rejecting the save here would refuse one - // that loads correctly today. - prepared.moduleIfo = decodeSaveGff( - prepared.session->findWorking(ResourceId("module", ResType::Ifo))); + // Save-wide records remain optional where retail permits them, but any + // record supplied by the slot is decoded while the candidate is private. prepared.partyTable = decodeSaveGff( prepared.session->findMetadata(ResourceId("partytable", ResType::Res))); prepared.inventory = decodeSaveGff( prepared.session->findWorking(ResourceId("inventory", ResType::Res))); + prepared.destination = prepareDestinationModule( + prepared.nfo.lastModule, + /*initialSaveRestore=*/true, + prepared.session->workingState()); + if (prepared.destination.context == + ModuleLoadContext::InitialTemplateRestore) { + prepared.playerInfo = decodeSaveGff( + prepared.session->findMetadata(ResourceId("pifo", ResType::Ifo))); + if (!prepared.playerInfo || + prepared.playerInfo->getList("Mod_PlayerList").empty()) { + throw ValidationException( + "Template-world save restore requires pifo.ifo player state"); + } + auto params = prepared.saveInfo->findStruct("AUTOSAVEPARAMS"); + if (!params) { + throw ValidationException( + "Template-world save restore requires AUTOSAVEPARAMS"); + } + PreparedSaveLoad::AutosaveRestoreState autosave; + autosave.startWaypoint = params->getString("STARTWAYPOINT"); + if (autosave.startWaypoint == "*") { + autosave.startWaypoint.clear(); + } + autosave.pauseDay = params->getUint("TIME_PAUSEDAY"); + autosave.pauseTime = params->getUint("TIME_PAUSETIME"); + prepared.autosave = std::move(autosave); + } + validatePartyLoad(prepared.partyTable.get()); return prepared; } -void Game::loadGame(const resource::SaveSlotDescriptor &slot) { +bool Game::loadGame(const resource::SaveSlotDescriptor &slot) { info(str(boost::format("Loading savegame '%s'") % slot.directory.filename().string())); // Resolve and validate the replacement before anything is given up. A @@ -1289,14 +1744,13 @@ void Game::loadGame(const resource::SaveSlotDescriptor &slot) { // player keeps playing instead of being left with nothing to render. auto prepared = prepareSaveLoad(slot); - // Commit. The old runtime and the old mounts retire together, and only - // then does the candidate become authoritative: no runtime ever observes - // the other session's resources. - resetGame(); - _services.resource.director.commitGameLoad(std::move(prepared.session)); - try { - restoreSaveLoad(std::move(prepared)); + // Commit. The old runtime and the old mounts retire together, and only + // then does the candidate become authoritative: no runtime ever + // observes the other session's resources. + resetGame(); + _services.resource.director.commitGameLoad(std::move(prepared.session)); + return restoreSaveLoad(std::move(prepared)); } catch (const std::exception &e) { // Past the commit boundary the previous session no longer exists and // cannot be restored. Retire whatever was half-built and land on a @@ -1304,20 +1758,27 @@ void Game::loadGame(const resource::SaveSlotDescriptor &slot) { // leaves behind. error("Failed restoring savegame '" + slot.directory.filename().string() + "': " + std::string(e.what())); - retireToMainMenu(); - return; + try { + retireToMainMenu(); + } catch (const std::exception &terminalError) { + error("Failed entering Main Menu after save-load failure: " + + std::string(terminalError.what())); + _screen = Screen::MainMenu; + } + return false; } } -void Game::restoreSaveLoad(PreparedSaveLoad prepared) { +bool Game::restoreSaveLoad(PreparedSaveLoad prepared) { const NFO &nfo = prepared.nfo; _cheatUsed = nfo.cheatUsed; captureSaveResourceShadow({SaveResourceKind::Nfo, {}}, *prepared.saveInfo); - // Add module files to resource resolution. Since all savegame files are - // already in scope, this is going to resolve to the last module from the - // save game. - _services.resource.director.onModuleLoad(nfo.lastModule); + // The exact plan inspected before reset now replaces the active module + // owners. A file changing between preparation and this point is a + // post-commit failure and follows #325's deliberate terminal policy. + _services.resource.director.commitModuleLoad( + std::move(prepared.destination.resources)); // Restore the save-wide faction table before any module objects can query // disposition. A missing or malformed optional FAC starts from fresh base @@ -1343,42 +1804,64 @@ void Game::restoreSaveLoad(PreparedSaveLoad prepared) { // still unpublished. deserializeGlobalVariables(*prepared.globalVars); - // Deserialize party. The archive usually carries its own module IFO; when - // it does not, the mounted module still supplies one. - std::shared_ptr ifo = prepared.moduleIfo - ? prepared.moduleIfo - : _services.resource.gffs.get("module", ResType::Ifo); - if (!ifo) { - throw ResourceNotFoundException("Module IFO not found"); - } - captureSaveResourceShadow({SaveResourceKind::ModuleIfo, nfo.lastModule}, *ifo); + // Deserialize party from records inspected before the old session was + // retired. A disk restore and a saved module graph are independent: retail + // transition autosaves restore save-wide state while constructing the + // module world from installed templates and the player from pifo.ifo. + const auto &ifo = prepared.destination.ifo; replaceCustomTokens(parseCustomTokens(*ifo)); - prepareSavedRuntimeNamespace(*ifo); - - // Reserve serialized identities before party/inventory reconstruction can - // allocate owner-local support objects. The active GIT is mounted as - // module state rather than as a top-level working-state resource. - for (const auto &id : _services.resource.director.saveWorkingResourceIds()) { - if (!resource::isGFFCompatibleResType(id.type)) { - continue; - } - try { - if (auto gff = decodeSaveGff( - _services.resource.director.findSaveWorking(id))) { - reserveSavedObjectIds(*gff); - } - } catch (const std::exception &) { - // A generic .res is not necessarily GFF. Required structured - // resources retain their normal validation path below. + std::string entry; + if (restoresSavedWorld(prepared.destination.context)) { + captureSaveResourceShadow( + {SaveResourceKind::ModuleIfo, prepared.destination.name}, *ifo); + const auto moduleIdentityContext = + SerializedIdentityContext::moduleGraph(prepared.destination.name); + prepareSavedRuntimeNamespace(*ifo, moduleIdentityContext); + + // Detached save-wide records are deliberately not traversed here: + // their ObjectId fields do not claim identities in the module graph. + reserveSavedObjectIds( + *prepared.destination.git, + moduleIdentityContext, + SerializedGraphRoot::AreaGit); + deserializeParty(*ifo, prepared.partyTable, moduleIdentityContext); + } else { + if (!prepared.autosave || !prepared.playerInfo) { + throw ValidationException( + "Template-world save restore state was not prepared"); + } + restoreWorldTime( + *ifo, prepared.autosave->pauseDay, prepared.autosave->pauseTime); + deserializeParty( + *prepared.playerInfo, + prepared.partyTable, + SerializedIdentityContext::detachedRecord("pifo.ifo")); + entry = prepared.autosave->startWaypoint; + } + + // Retail CreateParty clears the detached NPC/PUP ActionList when it + // respawns those representations as part of a full disk restore. This is + // intentionally different from ordinary travel, whose UpdateMembers(0) + // queue is restored below. The module player is loaded through the IFO or + // pifo path and is not one of these spawned detached followers. + auto discardDetachedRosterActions = [](const std::shared_ptr &creature) { + if (!creature) return; + creature->_savedActionQueue = SavedActionQueue {}; + creature->_savedActionReferencesBound.clear(); + creature->_loadedSaveActionSlots.clear(); + }; + for (const auto &member : _party.members()) { + if (member.creature && member.creature != _party.player() && + member.creature != _party.actualPlayer()) { + discardDetachedRosterActions(member.creature); } } - const std::string entryArea = ifo->getString("Mod_Entry_Area"); - if (!entryArea.empty()) { - if (auto git = _services.resource.gffs.get(entryArea, ResType::Git)) { - reserveSavedObjectIds(*git); + if (isTSL()) { + for (int puppet : _party.persistedState().puppetIds) { + discardDetachedRosterActions( + _party.getAvailablePuppet(puppet, true)); } } - deserializeParty(*ifo, prepared.partyTable); // Once the player is loaded, deserialize player's inventory. if (prepared.inventory) { @@ -1387,8 +1870,53 @@ void Game::restoreSaveLoad(PreparedSaveLoad prepared) { deserializeInventory(*prepared.inventory); } - // Warp to the last module. - loadModule(nfo.lastModule, /*entry=*/"", /*fromSave=*/true); + return loadPreparedModule( + std::move(prepared.destination), + std::move(entry), + /*initialSaveRestore=*/true, + /*resourcesCommitted=*/true); +} + +void Game::validatePartyLoad(const resource::Gff *partyTable) const { + if (!partyTable) { + return; + } + + const auto state = parsePartyTable(*partyTable); + const auto validNpc = [this](int npc) { + const int count = isTSL() + ? static_cast(Party::kK2NpcCount) + : static_cast(Party::kK1NpcCount); + return npc >= 0 && npc < count; + }; + if (state.controlledNpc != -1 && !validNpc(state.controlledNpc)) { + throw ValidationException("PartyTable controlled NPC is out of range"); + } + + std::set members; + for (int npc : state.memberIds) { + if (npc != kNpcPlayer && !validNpc(npc)) { + throw ValidationException("PartyTable member is out of range"); + } + if (!members.insert(npc).second) { + throw ValidationException("PartyTable contains a duplicate member"); + } + } + if (state.leader != -1 && state.leader != kNpcPlayer && + members.count(state.leader) == 0) { + throw ValidationException("PartyTable leader is not an active member"); + } + + std::set puppets; + for (int puppet : state.puppetIds) { + if (!isTSL() || puppet < 0 || + puppet >= static_cast(Party::kMaxPuppetCount)) { + throw ValidationException("PartyTable puppet is out of range"); + } + if (!puppets.insert(puppet).second) { + throw ValidationException("PartyTable contains a duplicate puppet"); + } + } } std::map Game::parseCustomTokens( @@ -1436,7 +1964,10 @@ void Game::deserializeGlobalVariables(resource::Gff &gvtGff) { } } -void Game::deserializeParty(resource::Gff &ifoGff, const std::shared_ptr &ptGff) { +void Game::deserializeParty( + resource::Gff &ifoGff, + const std::shared_ptr &ptGff, + const SerializedIdentityContext &moduleIdentityContext) { resetGalaxyMap(); std::shared_ptr pcGff; @@ -1456,13 +1987,14 @@ void Game::deserializeParty(resource::Gff &ifoGff, const std::shared_ptr &p } } - publishPartyRuntimeState(ifoGff, ptGff, pcGff); + publishPartyRuntimeState(ifoGff, ptGff, pcGff, moduleIdentityContext); } void Game::publishPartyRuntimeState( resource::Gff &ifoGff, const std::shared_ptr &ptGff, - const std::shared_ptr &pcGff) { + const std::shared_ptr &pcGff, + const SerializedIdentityContext &moduleIdentityContext) { if (ptGff) { captureSaveResourceShadow({SaveResourceKind::PartyTable, {}}, *ptGff); deserializeGalaxyMap(*ptGff); @@ -1481,10 +2013,6 @@ void Game::publishPartyRuntimeState( Party::PersistedState partyState = parsePartyTable(*ptGff); replacePartyTable(std::move(partyState)); deserializePazaakPartyTable(*ptGff); - // Retail constructs the available/limbo creature records before - // completing the primary player. Keeping that boundary also ensures - // every party object exists before saved references are bound. - deserializeAvailableNpcs(); } const auto &players = ifoGff.getList("Mod_PlayerList"); @@ -1492,33 +2020,33 @@ void Game::publishPartyRuntimeState( return; } - auto modulePlayer = newCreature(*players.front()); - modulePlayer->deserialize(*players.front()); + auto modulePlayer = newCreature(*players.front(), moduleIdentityContext); modulePlayer->captureSaveRecord( - *players.front(), {SaveRecordOriginKind::ModulePlayer, {}}); - modulePlayer->setTag(kObjectTagPlayer); + *players.front(), moduleIdentityContext, {SaveRecordOriginKind::ModulePlayer, {}}); + if (modulePlayer->tag().empty()) { + modulePlayer->setTag(kObjectTagPlayer); + } auto actualPlayer = modulePlayer; const auto &partyState = _party.persistedState(); // PT_CONTROLLED_NP, not Mod_IsPrimaryPlr, defines whether pc.utc is the // canonical player distinct from the currently controlled module creature. if (partyState.controlledNpc != -1 && pcGff) { - actualPlayer = pcGff->has("ObjectId") - ? newCreature(*pcGff) - : newCreature(); - actualPlayer->deserialize(*pcGff); + const auto pcIdentityContext = + SerializedIdentityContext::detachedRecord("pc.utc"); + actualPlayer = newCreature(*pcGff, pcIdentityContext); actualPlayer->captureSaveRecord( - *pcGff, {SaveRecordOriginKind::PrimaryPlayerUtc, {}}); + *pcGff, pcIdentityContext, {SaveRecordOriginKind::PrimaryPlayerUtc, {}}); } - // Retail K1 and K2 complete primary-player BIC publication by assigning - // the derived maximum HP after creature load. Keep this explicit and - // separate from generic creature deserialization: corpses, party NPCs and - // unrelated serialized PCs must retain their archived health state. - actualPlayer->restorePrimaryPlayerHitPoints(); - _party.setPlayer(modulePlayer); _party.setActualPlayer(actualPlayer); + if (partyState.controlledNpc != -1 && + !_party.bindRosterCreature( + {RosterKind::Npc, partyState.controlledNpc}, modulePlayer)) { + throw ValidationException( + "Controlled NPC could not bind its logical roster slot"); + } if (ptGff) { deserializePartyMembers(*ptGff); @@ -1629,7 +2157,7 @@ Party::PersistedState Game::parsePartyTable(const resource::Gff &ptGff) const { } void Game::replacePartyTable(Party::PersistedState state) { - _party.setPersistedState(std::move(state)); + _party.loadPersistedState(std::move(state)); } void Game::resetGalaxyMap() { @@ -1700,81 +2228,95 @@ void Game::saveNpcState(int npc) { return; } - // The same record a save writes for this roster slot, produced by the same - // serializer, so a companion persisted here reads back exactly as one - // persisted by saving would. - auto candidate = resource::SaveWorkingStateCandidate::fromCommitted( - _services.resource.director.committedSaveWorkingState()); + saveRosterState({RosterKind::Npc, npc}, *creature); +} + +void Game::saveRosterState( + const RosterIdentity &identity, + const Creature &creature) { + if (!_party.isRosterIdentityValid(identity)) { + throw ValidationException("Roster slot is outside the title range"); + } + const std::string prefix = + identity.kind == RosterKind::Npc ? "availnpc" : "availpup"; + auto committed = _services.resource.director.committedSaveWorkingState(); + if (!committed) { + committed = std::make_shared(); + } + auto candidate = + resource::SaveWorkingStateCandidate::fromCommitted(std::move(committed)); candidate.put( - ResourceId("availnpc" + std::to_string(npc), ResType::Utc), - SaveWideSnapshotBuilder::availableNpcRecord(*this, *creature)); + ResourceId(prefix + std::to_string(identity.slot), ResType::Utc), + SaveWideSnapshotBuilder::availableNpcRecord(*this, creature)); _services.resource.director.adoptSaveWorkingState(candidate.freeze()); } -void Game::deserializeAvailableNpcs() { - const auto &persisted = _party.persistedState(); - size_t npcCount = isTSL() ? Party::kK2NpcCount : Party::kK1NpcCount; - for (size_t npc = 0; npc < npcCount; ++npc) { - if (!persisted.npcAvailable[npc]) { - continue; - } - std::string utc = str(boost::format("availnpc%d") % npc); - - std::shared_ptr utcGff; - try { - utcGff = decodeSaveGff( - _services.resource.director.findSaveWorking(ResourceId(utc, ResType::Utc))); - } catch (const std::exception &e) { - warn("Game: invalid " + utc + ".utc: " + std::string(e.what())); - continue; - } - if (!utcGff) { - warn("Game: missing " + utc + ".utc"); - continue; - } - - std::shared_ptr creature = utcGff->has("ObjectId") - ? newCreature(*utcGff) - : newCreature(); - creature->deserialize(*utcGff); - creature->captureSaveRecord( - *utcGff, - {SaveRecordOriginKind::AvailableNpc, std::to_string(npc)}); +std::shared_ptr Game::materializeRosterCreature( + const RosterIdentity &identity) { + if (!_party.isRosterAvailable(identity)) return nullptr; + if (auto existing = _party.rosterCreature(identity)) return existing; - _party.addAvailableMember(static_cast(npc), creature); + const std::string prefix = + identity.kind == RosterKind::Npc ? "availnpc" : "availpup"; + const std::string name = prefix + std::to_string(identity.slot); + std::shared_ptr record; + try { + record = decodeSaveGff( + _services.resource.director.findSaveWorking( + ResourceId(name, ResType::Utc))); + } catch (const std::exception &e) { + warn("Game: invalid " + name + ".utc: " + std::string(e.what())); + return nullptr; } - - if (!isTSL()) { - return; + if (!record) { + warn("Game: missing " + name + ".utc"); + return nullptr; } - for (size_t puppet = 0; puppet < Party::kMaxPuppetCount; ++puppet) { - if (!persisted.puppetAvailable[puppet]) { - continue; - } - std::string utc = str(boost::format("availpup%d") % puppet); - std::shared_ptr utcGff; - try { - utcGff = decodeSaveGff( - _services.resource.director.findSaveWorking(ResourceId(utc, ResType::Utc))); - } catch (const std::exception &e) { - warn("Game: invalid " + utc + ".utc: " + std::string(e.what())); - continue; - } - if (!utcGff) { - warn("Game: missing " + utc + ".utc"); - continue; - } - - auto creature = utcGff->has("ObjectId") - ? newCreature(*utcGff) - : newCreature(); - creature->deserialize(*utcGff); + const auto context = + SerializedIdentityContext::detachedRecord(name + ".utc"); + auto creature = newCreature(*record, context); + try { creature->captureSaveRecord( - *utcGff, - {SaveRecordOriginKind::AvailablePuppet, std::to_string(puppet)}); - _party.addAvailablePuppet(static_cast(puppet), std::move(creature)); + *record, + context, + {identity.kind == RosterKind::Npc + ? SaveRecordOriginKind::AvailableNpc + : SaveRecordOriginKind::AvailablePuppet, + std::to_string(identity.slot)}); + if (!_party.bindRosterCreature(identity, creature)) { + throw ValidationException("Could not bind materialized roster creature"); + } + // Initial restoration performs one graph-wide bind/publication after + // every object exists. A retail-style lazy GetNPCObject call in an + // already playable session must complete the same detached-record + // publication for this one newly materialized object immediately. + if (_runtimeSessionPlayable) { + creature->resolveSavedReferences( + [this, context](uint32_t id) { + return resolveSerializedObjectReference(id, context); + }); + creature->bindSavedRuntimeState(); + creature->publishSavedRuntimeState(); + } + } catch (...) { + destroyRuntimeObjectGraph(creature); + throw; + } + return creature; +} + +bool Game::killRosterCreature(const RosterIdentity &identity) { + auto creature = _party.rosterCreature(identity); + if (!creature) { + _party.clearRosterCreature(identity); + return false; + } + if (_module && _module->area()) { + _module->area()->retireCreatureAreaRuntime(creature); } + destroyRuntimeObjectGraph(creature); + return true; } void Game::deserializePartyMembers(resource::Gff &ptGff) { @@ -1800,7 +2342,7 @@ void Game::deserializePartyMembers(resource::Gff &ptGff) { npc == _party.persistedState().controlledNpc && _party.player() != _party.actualPlayer() ? _party.player() - : _party.getAvailableMember(npc); + : _party.getAvailableMember(npc, true); if (!member) { warn("Game: NPC is not available: " + std::to_string(npc)); return; @@ -1856,15 +2398,12 @@ void Game::deserializeInventory(resource::Gff &inventoryGff) { if (!player) { return; } - - for (const auto &itemGff : inventoryGff.getList("ItemList")) { - std::shared_ptr item = newOwnedItem(); - item->deserialize(*itemGff); - item->captureOwnerLocalSaveRecord( - *itemGff, - {SaveRecordOriginKind::PartyInventoryItem, "inventory"}); - player->addItem(item); - } + player->deserializeOwnedItems( + inventoryGff, + SerializedIdentityContext::detachedRecord("inventory.res"), + SaveRecordOriginKind::PartyInventoryItem, + false, + "inventory"); } bool Game::loadParty() { @@ -1877,16 +2416,13 @@ bool Game::loadParty() { } void Game::loadDefaultParty() { - // A new game starts with the authored basic collection for the running title. - _party.setDefaultPazaakData( - isTSL() ? Party::kK2PazaakCardCount : Party::kK1PazaakCardCount); + _party.initializeNewGameState(); std::string member1, member2, member3; _party.defaultMembers(member1, member2, member3); if (!member1.empty()) { - std::shared_ptr player = newCreature(); - _objectById.insert(std::make_pair(player->id(), player)); - player->loadFromBlueprint(member1); + std::shared_ptr player = + newCreatureFromBlueprint(member1); player->setTag(kObjectTagPlayer); player->setImmortal(true); _party.addMember(kNpcPlayer, player); @@ -1894,18 +2430,18 @@ void Game::loadDefaultParty() { _party.setActualPlayer(player); } if (!member2.empty()) { - std::shared_ptr companion = newCreature(); - _objectById.insert(std::make_pair(companion->id(), companion)); - companion->loadFromBlueprint(member2); + std::shared_ptr companion = + newCreatureFromBlueprint(member2); companion->setImmortal(true); companion->equip("g_w_dblsbr001"); + _party.addAvailableMember(0, companion); _party.addMember(0, companion); } if (!member3.empty()) { - std::shared_ptr companion = newCreature(); - _objectById.insert(std::make_pair(companion->id(), companion)); - companion->loadFromBlueprint(member3); + std::shared_ptr companion = + newCreatureFromBlueprint(member3); companion->setImmortal(true); + _party.addAvailableMember(1, companion); _party.addMember(1, companion); } } @@ -2002,6 +2538,202 @@ std::shared_ptr Game::getObjectById(uint32_t id) const { } } +bool Game::isRuntimeObjectLive(const Object &object) const { + if (!object.isRuntimeLive()) { + return false; + } + auto found = _objectById.find(object.id()); + return found != _objectById.end() && found->second.get() == &object; +} + +bool Game::isRuntimeObjectAttachable(const Object &object) const { + if (isRuntimeObjectLive(object)) { + return true; + } + if (object._runtimeState != Object::RuntimeState::Constructing || + !_stagedRuntimeObjectGraph) { + return false; + } + auto found = _stagedRuntimeObjectGraph->objectById.find(object.id()); + return found != _stagedRuntimeObjectGraph->objectById.end() && + found->second.get() == &object; +} + +void Game::unregisterRuntimeObject(const std::shared_ptr &object) { + if (!object) { + return; + } + object->_runtimeState = Object::RuntimeState::Retired; + if (auto creature = std::dynamic_pointer_cast(object)) { + // A PartyTable binding denotes a live runtime object, not storage + // ownership. Pointer-guarded clearing cannot disturb a replacement + // that reused the same runtime number. + _party.clearRosterCreature(*creature); + } + auto registered = _objectById.find(object->id()); + if (registered != _objectById.end() && + registered->second.get() == object.get()) { + _objectById.erase(registered); + } + + // Include the structural Module alias, which deliberately has no reverse + // serialized identity on the Module object itself. + for (auto it = _objectBySavedId.begin(); it != _objectBySavedId.end();) { + if (it->second.lock().get() == object.get()) { + it = _objectBySavedId.erase(it); + } else { + ++it; + } + } + _savedIdByObject.erase(object.get()); +} + +void Game::destroyRuntimeObjectGraph(const std::shared_ptr &object) { + if (!object) { + return; + } + auto graph = collectRuntimeObjectGraph({object}); + // Children cease to exist before their owner. The pointer guard makes this + // idempotent and protects a newer object if an explicit ID was reused. + for (auto it = graph.rbegin(); it != graph.rend(); ++it) { + unregisterRuntimeObject(*it); + } +} + +std::vector> Game::collectRuntimeObjectGraph( + const std::vector> &roots) const { + std::vector> pending(roots); + std::set seen; + std::vector> graph; + while (!pending.empty()) { + auto current = std::move(pending.back()); + pending.pop_back(); + if (!current || !seen.insert(current.get()).second) { + continue; + } + graph.push_back(current); + for (auto &owned : current->ownedRuntimeObjects()) { + pending.push_back(std::move(owned)); + } + } + return graph; +} + +void Game::discardStagedRuntimeObjects( + const std::vector> &objects) { + if (!_stagedRuntimeObjectGraph) { + throw ValidationException("No staged runtime object graph is active"); + } + auto &staged = *_stagedRuntimeObjectGraph; + for (const auto &object : objects) { + if (!object) { + continue; + } + auto byId = staged.objectById.find(object->id()); + if (byId != staged.objectById.end() && + byId->second.get() == object.get()) { + staged.objectById.erase(byId); + } + for (auto it = staged.objectBySavedId.begin(); + it != staged.objectBySavedId.end();) { + if (it->second.lock().get() == object.get()) { + it = staged.objectBySavedId.erase(it); + } else { + ++it; + } + } + staged.savedIdByObject.erase(object.get()); + staged.publishedRuntimeObjectIds.erase(object->id()); + staged.reservedSavedObjectIdsToRelease.erase(object->id()); + object->_runtimeState = Object::RuntimeState::Retired; + } + staged.candidateObjects.erase( + std::remove_if( + staged.candidateObjects.begin(), + staged.candidateObjects.end(), + [&](const auto &candidate) { + return std::find(objects.begin(), objects.end(), candidate) != + objects.end(); + }), + staged.candidateObjects.end()); +} + +void Game::beginRuntimeObjectGraphReplacement( + const std::vector> &obsoleteObjects) { + if (_stagedRuntimeObjectGraph) { + throw ValidationException("Nested runtime object graph replacement"); + } + _stagedRuntimeObjectGraph.emplace(); + _stagedRuntimeObjectGraph->initialNextObjectId = _nextObjectId; + + _stagedRuntimeObjectGraph->obsoleteGraph = + collectRuntimeObjectGraph(obsoleteObjects); + for (const auto &object : _stagedRuntimeObjectGraph->obsoleteGraph) { + _stagedRuntimeObjectGraph->replaceableObjects.insert(object.get()); + } +} + +void Game::commitRuntimeObjectGraphReplacement( + const std::vector> &) { + if (!_stagedRuntimeObjectGraph) { + throw ValidationException("No runtime object graph replacement is active"); + } + + auto staged = std::move(*_stagedRuntimeObjectGraph); + _stagedRuntimeObjectGraph.reset(); + + // The complete old graph was discovered before candidate construction and + // before the no-throw ownership publication. Nothing below walks ownership + // or allocates graph bookkeeping after that irreversible boundary. + for (auto it = staged.obsoleteGraph.rbegin(); + it != staged.obsoleteGraph.rend(); ++it) { + unregisterRuntimeObject(*it); + } + _objectById.merge(staged.objectById); + _publishedRuntimeObjectIds.merge(staged.publishedRuntimeObjectIds); + _objectBySavedId.merge(staged.objectBySavedId); + _savedIdByObject.merge(staged.savedIdByObject); + for (const auto &object : staged.candidateObjects) { + auto found = _objectById.find(object->id()); + if (found != _objectById.end() && + found->second.get() == object.get()) { + object->_runtimeState = Object::RuntimeState::Live; + } + } + for (uint32_t id : staged.reservedSavedObjectIdsToRelease) { + _reservedSavedObjectIds.erase(id); + } + + // All collisions are rejected while staging. A non-empty source here + // would mean the supposedly atomic publication silently lost a node. + if (!staged.objectById.empty() || + !staged.publishedRuntimeObjectIds.empty() || + !staged.objectBySavedId.empty() || + !staged.savedIdByObject.empty()) { + std::terminate(); + } +} + +void Game::abortRuntimeObjectGraphReplacement() { + if (!_stagedRuntimeObjectGraph) { + return; + } + for (const auto &object : _stagedRuntimeObjectGraph->candidateObjects) { + object->_runtimeState = Object::RuntimeState::Retired; + } + _nextObjectId = _stagedRuntimeObjectGraph->initialNextObjectId; + _stagedRuntimeObjectGraph.reset(); +} + +std::shared_ptr Game::getObjectBySavedId(uint32_t id) const { + auto found = _objectBySavedId.find(id); + if (found == _objectBySavedId.end()) { + return nullptr; + } + auto object = found->second.lock(); + return object && isRuntimeObjectLive(*object) ? object : nullptr; +} + uint32_t Game::savedObjectId(const resource::Gff &gff) const { uint32_t id = 0; if (!gff.readDword(id, "ObjectId")) { @@ -2020,76 +2752,277 @@ void Game::registerObject( if (!allowReserved && id < kFirstRuntimeObjectId) { throw ValidationException("Reserved saved ObjectId: " + std::to_string(id)); } - if (!_objectById.emplace(id, object).second) { - throw ValidationException("Duplicate saved ObjectId: " + std::to_string(id)); + if (object->_runtimeState != Object::RuntimeState::Constructing || + object->_runtimeIncarnation != 0) { + throw ValidationException("Runtime object storage cannot be republished"); + } + if (_objectById.count(id) != 0) { + throw ValidationException("Duplicate runtime ObjectId: " + std::to_string(id)); + } + if (_publishedRuntimeObjectIds.count(id) != 0) { + throw ValidationException( + "Runtime ObjectId was already published in this session: " + + std::to_string(id)); } - _reservedSavedObjectIds.erase(id); + object->_runtimeIncarnation = _nextRuntimeIncarnation++; + if (_stagedRuntimeObjectGraph) { + if (!_stagedRuntimeObjectGraph->publishedRuntimeObjectIds.insert(id).second) { + throw ValidationException( + "Runtime ObjectId was already staged in this session: " + + std::to_string(id)); + } + if (!_stagedRuntimeObjectGraph->objectById.emplace(id, object).second) { + throw ValidationException( + "Duplicate staged runtime ObjectId: " + std::to_string(id)); + } + _stagedRuntimeObjectGraph->candidateObjects.push_back(object); + _stagedRuntimeObjectGraph->reservedSavedObjectIdsToRelease.insert(id); + } else { + _publishedRuntimeObjectIds.insert(id); + _objectById.emplace(id, object); + object->_runtimeState = Object::RuntimeState::Live; + _reservedSavedObjectIds.erase(id); + } +} + +void Game::registerSavedObjectIdentity( + uint32_t id, + const std::shared_ptr &object, + const SerializedIdentityContext &identityContext) { + if (!identityContext.hasAuthoritativeObjectIds()) { + throw ValidationException( + "Cannot register a non-authoritative saved object identity"); + } + if (!_reservedSavedIdentityNamespace) { + _reservedSavedIdentityNamespace = identityContext.identityNamespace; + } else if (*_reservedSavedIdentityNamespace != + identityContext.identityNamespace) { + throw ValidationException("Saved object identity namespace is not active"); + } + if (id == std::numeric_limits::max() || !object) { + throw ValidationException("Invalid saved ObjectId mapping"); + } + auto existing = getObjectBySavedId(id); + if (existing && existing.get() != object.get() && + (!_stagedRuntimeObjectGraph || + !_stagedRuntimeObjectGraph->replaceableObjects.count(existing.get()))) { + throw ValidationException( + "Duplicate authoritative saved ObjectId mapping: " + + std::to_string(id)); + } + if (_stagedRuntimeObjectGraph) { + auto staged = _stagedRuntimeObjectGraph->objectBySavedId.find(id); + if (staged != _stagedRuntimeObjectGraph->objectBySavedId.end() && + staged->second.lock().get() != object.get()) { + throw ValidationException( + "Duplicate authoritative saved ObjectId mapping: " + + std::to_string(id)); + } + } + const auto &reverseMap = _stagedRuntimeObjectGraph + ? _stagedRuntimeObjectGraph->savedIdByObject + : _savedIdByObject; + auto reverse = reverseMap.find(object.get()); + if (reverse != reverseMap.end() && reverse->second != id) { + throw ValidationException("Runtime object has multiple saved ObjectIds"); + } + auto &savedMap = _stagedRuntimeObjectGraph + ? _stagedRuntimeObjectGraph->objectBySavedId + : _objectBySavedId; + savedMap[id] = object; + auto &canonicalId = _stagedRuntimeObjectGraph + ? _stagedRuntimeObjectGraph->savedIdByObject[object.get()] + : _savedIdByObject[object.get()]; + canonicalId = id; + object->assignSerializedObjectIdentity({identityContext, id}); +} + +void Game::registerSavedModuleReferenceTarget( + const std::shared_ptr &module, + const SerializedIdentityContext &identityContext) { + if (!identityContext.hasAuthoritativeObjectIds() || !module) { + throw ValidationException("Invalid saved structural Module target"); + } + if (!_reservedSavedIdentityNamespace) { + _reservedSavedIdentityNamespace = identityContext.identityNamespace; + } else if (*_reservedSavedIdentityNamespace != + identityContext.identityNamespace) { + throw ValidationException("Saved object identity namespace is not active"); + } + if (_reservedSavedObjectIds.count(kSavedRuntimeModuleObjectId) != 0) { + throw ValidationException( + "Saved ObjectId 0 collides with the structural Module target"); + } + auto existing = getObjectBySavedId(kSavedRuntimeModuleObjectId); + if (existing && existing.get() != module.get()) { + throw ValidationException("Duplicate saved structural Module target"); + } + auto &savedMap = _stagedRuntimeObjectGraph + ? _stagedRuntimeObjectGraph->objectBySavedId + : _objectBySavedId; + auto [found, inserted] = savedMap.emplace( + kSavedRuntimeModuleObjectId, module); + if (!inserted) { + auto existing = found->second.lock(); + if (!existing || existing.get() != module.get()) { + throw ValidationException( + "Duplicate saved structural Module target"); + } + } +} + +std::shared_ptr Game::newItem( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + return newObjectFromGff(gff, identityContext, *this, _services); +} + +std::shared_ptr Game::newItemFromBlueprint(const std::string &resRef) { + std::vector> noObsolete; + std::shared_ptr item; + replaceRuntimeObjectGraph( + noObsolete, + [&]() { + item = newItem(); + item->loadFromBlueprint(resRef); + }, + []() noexcept {}); + return item; +} + +std::shared_ptr Game::newItemClone(const Item &source) { + std::vector> noObsolete; + std::shared_ptr item; + replaceRuntimeObjectGraph( + noObsolete, + [&]() { + item = newItem(); + item->clone(source); + }, + []() noexcept {}); + return item; +} + +std::shared_ptr Game::newOwnedItem( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + return newObjectFromGff(gff, identityContext, *this, _services); } -std::shared_ptr Game::newItem(const resource::Gff &gff) { - return gff.has("ObjectId") - ? newObjectFromGff(gff, *this, _services) - : newItem(); +std::shared_ptr Game::newSavedArea( + uint32_t id, + const SerializedIdentityContext &identityContext, + std::string sceneName) { + std::vector> noObsolete; + std::shared_ptr area; + replaceRuntimeObjectGraph( + noObsolete, + [&]() { + area = newArea(std::move(sceneName)); + registerSavedObjectIdentity(id, area, identityContext); + }, + []() noexcept {}); + return area; } std::shared_ptr Game::newCreature( const resource::Gff &gff, + const SerializedIdentityContext &identityContext, std::string sceneName) { return newObjectFromGff( - gff, std::move(sceneName), *this, _services); + gff, identityContext, std::move(sceneName), *this, _services); +} + +std::shared_ptr Game::newCreatureFromBlueprint( + const std::string &resRef, + std::string sceneName) { + std::vector> noObsolete; + std::shared_ptr creature; + replaceRuntimeObjectGraph( + noObsolete, + [&]() { + creature = newCreature(std::move(sceneName)); + creature->loadFromBlueprint(resRef); + }, + []() noexcept {}); + return creature; } std::shared_ptr Game::newPlaceable( const resource::Gff &gff, + const SerializedIdentityContext &identityContext, std::string sceneName) { return newObjectFromGff( - gff, std::move(sceneName), *this, _services); + gff, identityContext, std::move(sceneName), *this, _services); +} + +std::shared_ptr Game::newPlaceableFromBlueprint( + const std::string &resRef, + std::string sceneName) { + std::vector> noObsolete; + std::shared_ptr placeable; + replaceRuntimeObjectGraph( + noObsolete, + [&]() { + placeable = newPlaceable(std::move(sceneName)); + placeable->loadFromBlueprint(resRef); + }, + []() noexcept {}); + return placeable; } std::shared_ptr Game::newDoor( const resource::Gff &gff, + const SerializedIdentityContext &identityContext, std::string sceneName) { return newObjectFromGff( - gff, std::move(sceneName), *this, _services); + gff, identityContext, std::move(sceneName), *this, _services); } std::shared_ptr Game::newWaypoint( const resource::Gff &gff, + const SerializedIdentityContext &identityContext, std::string sceneName) { return newObjectFromGff( - gff, std::move(sceneName), *this, _services); + gff, identityContext, std::move(sceneName), *this, _services); } std::shared_ptr Game::newTrigger( const resource::Gff &gff, + const SerializedIdentityContext &identityContext, std::string sceneName) { return newObjectFromGff( - gff, std::move(sceneName), *this, _services); + gff, identityContext, std::move(sceneName), *this, _services); } std::shared_ptr Game::newSound( const resource::Gff &gff, + const SerializedIdentityContext &identityContext, std::string sceneName) { return newObjectFromGff( - gff, std::move(sceneName), *this, _services); + gff, identityContext, std::move(sceneName), *this, _services); } std::shared_ptr Game::newEncounter( const resource::Gff &gff, + const SerializedIdentityContext &identityContext, std::string sceneName) { return newObjectFromGff( - gff, std::move(sceneName), *this, _services); + gff, identityContext, std::move(sceneName), *this, _services); } std::shared_ptr Game::newStore( const resource::Gff &gff, + const SerializedIdentityContext &identityContext, std::string sceneName) { return newObjectFromGff( - gff, std::move(sceneName), *this, _services); + gff, identityContext, std::move(sceneName), *this, _services); } -void Game::prepareSavedRuntimeNamespace(const resource::Gff &ifo) { - reserveSavedObjectIds(ifo); +void Game::prepareSavedRuntimeNamespace( + const resource::Gff &ifo, + const SerializedIdentityContext &identityContext) { + reserveSavedObjectIds(ifo, identityContext, SerializedGraphRoot::ModuleIfo); uint32_t nextObjectId = kFirstRuntimeObjectId; if (ifo.readDword(nextObjectId, "Mod_NextObjId0") && @@ -2105,9 +3038,28 @@ void Game::prepareSavedRuntimeNamespace(const resource::Gff &ifo) { throw ValidationException("Invalid Mod_Effect_NxtId"); } } - // Mod_MinPerHour first: it defines the day length that Mod_TimeOfDay is + + uint64_t pauseDay = 0; + uint64_t pauseTime = 0; + if (ifo.has("Mod_PauseDay") || ifo.has("Mod_PauseTime")) { + pauseDay = ifo.getUint("Mod_PauseDay"); + pauseTime = ifo.getUint("Mod_PauseTime"); + } else { + // Read-only compatibility for saves written by the short-lived Reone + // field-name mistake. New snapshots always emit the retail fields. + pauseDay = ifo.getUint("Mod_CalendarDay"); + pauseTime = ifo.getUint("Mod_TimeOfDay"); + } + restoreWorldTime(ifo, pauseDay, pauseTime); +} + +void Game::restoreWorldTime( + const resource::Gff &moduleIfo, + uint64_t pauseDay, + uint64_t pauseTime) { + // Mod_MinPerHour first: it defines the day length that Mod_PauseTime is // measured against. - uint32_t minutesPerHour = ifo.getUint("Mod_MinPerHour"); + uint32_t minutesPerHour = moduleIfo.getUint("Mod_MinPerHour"); if (minutesPerHour > std::numeric_limits::max()) { throw ValidationException("Invalid Mod_MinPerHour"); } @@ -2121,30 +3073,44 @@ void Game::prepareSavedRuntimeNamespace(const resource::Gff &ifo) { // became Mod_MinPerHour-derived hold a time of day on the old fixed // 24-hour scale, and must still load. Both fields are Dwords, so the // composition cannot overflow the 64-bit clock. - uint64_t day = ifo.getUint("Mod_CalendarDay"); - uint64_t timeOfDay = ifo.getUint("Mod_TimeOfDay"); - _worldTimeMilliseconds = day * millisecondsPerWorldDay() + timeOfDay; + _worldTimeMilliseconds = + pauseDay * millisecondsPerWorldDay() + pauseTime; _worldTimeFraction = 0.0; } -void Game::reserveSavedObjectIds(const resource::Gff &gff) { - for (const auto &field : gff.fields()) { - if (field.label == "ObjectId" && - field.type == resource::Gff::FieldType::Dword) { - _reservedSavedObjectIds.insert(field.uintValue); +void Game::reserveSavedObjectIds( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext, + SerializedGraphRoot graphRoot) { + if (!identityContext.hasAuthoritativeObjectIds()) { + return; + } + if (!_reservedSavedIdentityNamespace) { + _reservedSavedIdentityNamespace = identityContext.identityNamespace; + } else if (*_reservedSavedIdentityNamespace != identityContext.identityNamespace) { + throw ValidationException("Cannot mix authoritative saved object namespaces"); + } + for (auto &claim : collectSerializedObjectIdClaims(gff, identityContext, graphRoot)) { + if (claim.id == std::numeric_limits::max()) { + throw ValidationException("Invalid saved ObjectId"); } - for (const auto &child : field.children) { - reserveSavedObjectIds(*child); + auto [found, inserted] = _reservedSavedObjectIdClaims.emplace(claim.id, claim.path); + if (!inserted && found->second != claim.path) { + throw ValidationException( + "Duplicate authoritative saved ObjectId " + + std::to_string(claim.id) + " at " + found->second + + " and " + claim.path); } + _reservedSavedObjectIds.insert(claim.id); } } void Game::resolveSavedObjectReferences() { for (const auto &[_, object] : _objectById) { + const auto identityContext = object->_savedRuntimeIdentityContext; object->resolveSavedReferences( - [this](uint32_t id) { - auto found = _objectById.find(id); - return found == _objectById.end() ? nullptr : found->second; + [this, identityContext](uint32_t id) { + return resolveSerializedObjectReference(id, identityContext); }); } } @@ -3684,6 +4650,118 @@ void Game::openPartySelection(const PartySelectionContext &ctx) { changeScreen(Screen::PartySelection); } +bool Game::isPartySelectionRealized( + const std::vector &selectedNpcs) const { + if (!_module || !_module->area()) { + return false; + } + const auto &area = *_module->area(); + std::array requested {}; + for (int npc : selectedNpcs) { + const RosterIdentity identity {RosterKind::Npc, npc}; + if (!_party.isRosterIdentityValid(identity) || requested[npc]) { + return false; + } + requested[npc] = true; + } + + std::array realized {}; + for (const auto &member : _party.members()) { + if (member.npc == kNpcPlayer) { + continue; + } + const RosterIdentity identity {RosterKind::Npc, member.npc}; + if (!_party.isRosterIdentityValid(identity) || + !requested[member.npc] || realized[member.npc] || + !member.creature) { + return false; + } + auto bound = _party.rosterCreature(identity); + if (bound != member.creature || + !isRuntimeObjectLive(*bound) || + area.isObjectPendingDestruction(*bound) || + !area.isObjectResident(*bound)) { + return false; + } + realized[member.npc] = true; + } + + return realized == requested; +} + +bool Game::reconcilePartySelection( + const std::vector &selectedNpcs) { + if (!_module || !_module->area()) { + warn("Party selection: no active Area"); + return false; + } + + std::array requested {}; + for (int npc : selectedNpcs) { + const RosterIdentity identity {RosterKind::Npc, npc}; + if (!_party.isRosterIdentityValid(identity) || requested[npc] || + !_party.isRosterAvailable(identity)) { + warn("Party selection: NPC is not available: " + + std::to_string(npc)); + return false; + } + requested[npc] = true; + } + if (isPartySelectionRealized(selectedNpcs)) { + return true; + } + + auto area = _module->area(); + + // A queued representation remains live and Area-resident until the next + // update, but it cannot satisfy a selection which must survive that + // update. Drop only its exact roster binding so detached Party state can + // materialize a new incarnation. The queued destruction remains aimed at + // the obsolete object and cannot clear the replacement binding. + for (int npc : selectedNpcs) { + const RosterIdentity identity {RosterKind::Npc, npc}; + auto bound = _party.rosterCreature(identity); + if (bound && + (!isRuntimeObjectLive(*bound) || + area->isObjectPendingDestruction(*bound))) { + _party.clearRosterCreature(identity, bound.get()); + } + } + + const auto currentMembers = _party.members(); + for (const auto &member : currentMembers) { + if (member.npc != kNpcPlayer && + (member.npc < 0 || + member.npc >= static_cast(requested.size()) || + !requested[member.npc])) { + // This companion ceases to inhabit the current Area; unlike a + // control switch, party removal is a full Area-departure boundary. + area->retirePartyMemberAreaRuntime(member.creature); + } + } + + _party.clear(); + const int controlled = _party.controlledNpc(); + if (!_party.addMember( + controlled == kNpcPlayer ? kNpcPlayer : controlled, + _party.player())) { + warn("Party selection: could not restore the controlled character"); + return false; + } + + for (int npc : selectedNpcs) { + auto member = _party.getAvailableMember(npc, true); + if (!member || !_party.addMember(npc, member)) { + warn("Party selection: could not realize NPC: " + + std::to_string(npc)); + return false; + } + } + + area->repositionParty(); + return isPartySelectionRealized(selectedNpcs); +} + void Game::openSaveLoad(SaveLoadMode mode) { setRelativeMouseMode(false); setCursorType(CursorType::Default); @@ -4068,7 +5146,8 @@ void Game::finishPazaak(PazaakCompletedResult result) { std::string continuation(_pazaakSession->continuationScript()); uint32_t opponentId = _pazaakSession->opponentId(); - std::shared_ptr continuationCaller(_pazaakContinuationCaller.lock()); + std::shared_ptr continuationCaller( + _pazaakContinuationCaller.resolve()); bool developmentLaunch = _pazaakDevelopmentLaunch; int wager = _pazaakSession->wager(); bool callerValid = continuationCaller && @@ -4918,17 +5997,22 @@ void Game::consoleSpawnCreature(const ConsoleArgs &args) { auto leader = getConsoleLeader(); std::shared_ptr creature; - if (auto id = args.get(2)) { - if (getObjectById(id.value())) { + if (auto explicitId = args.get(2)) { + if (getObjectById(*explicitId)) { throw std::runtime_error("Object already exists"); } - creature = std::make_shared(id.value(), kSceneMain, *this, _services); - _objectById.insert(std::make_pair(creature->id(), creature)); + std::vector> noObsolete; + replaceRuntimeObjectGraph( + noObsolete, + [&]() { + creature = newObjectAtId( + *explicitId, false, kSceneMain, *this, _services); + creature->loadFromBlueprint(res); + }, + []() noexcept {}); } else { - creature = newCreature(); + creature = newCreatureFromBlueprint(res); } - - creature->loadFromBlueprint(res); creature->setPosition(leader->position()); creature->setFacing(leader->getFacing()); creature->setFaction(Faction::Neutral); @@ -4953,13 +6037,18 @@ void Game::consoleSpawnCompanion(const ConsoleArgs &args) { if (getObjectById(id.value())) { throw std::runtime_error("Object already exists"); } - companion = std::make_shared(id.value(), kSceneMain, *this, _services); - _objectById.insert(std::make_pair(companion->id(), companion)); + std::vector> noObsolete; + replaceRuntimeObjectGraph( + noObsolete, + [&]() { + companion = newObjectAtId( + id.value(), false, kSceneMain, *this, _services); + companion->loadFromBlueprint(res); + }, + []() noexcept {}); } else { - companion = newCreature(); + companion = newCreatureFromBlueprint(res); } - - companion->loadFromBlueprint(res); companion->setPosition(leader->position()); companion->setFacing(leader->getFacing()); companion->setFaction(leader->faction()); @@ -4967,6 +6056,7 @@ void Game::consoleSpawnCompanion(const ConsoleArgs &args) { area->landObject(*companion); area->add(companion); companion->runSpawnScript(); + _party.addAvailableMember(npc, companion); _party.addMember(npc, companion); } @@ -5225,6 +6315,9 @@ void Game::consoleSetAbility(const ConsoleArgs &args) { throw std::runtime_error("Invalid value"); } actor->attributes().setAbilityScore(ability.value(), value.value()); + if (ability.value() == Ability::Constitution) { + actor->recalculatePermanentVitality(); + } } void Game::consoleSetSkill(const ConsoleArgs &args) { @@ -5256,6 +6349,7 @@ void Game::consoleAddOrRemoveFeat(const ConsoleArgs &args) { } else { attrs.removeFeat(feat.value()); } + actor->recalculatePermanentVitality(); } void Game::consoleAddOrRemoveSpell(const ConsoleArgs &args) { @@ -5398,7 +6492,8 @@ void Game::consoleStartPazaak(const ConsoleArgs &args) { return; } - std::shared_ptr selected(_pazaakDevelopmentSelectedObjectOverride.lock()); + std::shared_ptr selected( + _pazaakDevelopmentSelectedObjectOverride.resolve()); if (!selected) { if (auto area = _module->area()) { selected = area->selectedObject(); diff --git a/src/libs/game/gui/chargen.cpp b/src/libs/game/gui/chargen.cpp index a8be5a8f2..a9340ee11 100644 --- a/src/libs/game/gui/chargen.cpp +++ b/src/libs/game/gui/chargen.cpp @@ -345,33 +345,44 @@ void CharacterGeneration::finish() { _character.attributes.addClassLevels(clazz.get(), 1); std::shared_ptr partyLeader(_game.party().getLeader()); partyLeader->attributes() = _character.attributes; + partyLeader->setMaxHitPoints( + partyLeader->hitPoints() + clazz->hitdie()); _game.openInGame(); } else { // Character preview objects belong to the pre-playable character- // generation runtime. Retire them before publishing the new game. _game.retireRuntimeSession(); - std::shared_ptr player = _game.newCreature(); - player->setTag(kObjectTagPlayer); - player->setName(_character.name); - player->setGender(_character.gender); - player->setAppearance(_character.appearance); - player->loadAppearance(); - player->setFaction(Faction::Friendly1); - player->setImmortal(true); - player->attributes() = _character.attributes; - - player->setOnHeartbeat("k_hen_heartbt01"); - player->setOnSpawn("k_hen_spawn01"); - player->setOnNotice("k_hen_percept01"); - player->setOnEndRound("k_hen_combend01"); - player->setOnAttacked("k_hen_attacked01"); - player->setOnDamaged("k_hen_damage01"); - player->setOnBlocked("k_hen_blocked01"); - player->setOnDialogue("k_hen_dialogue01"); + std::vector> noObsolete; + std::shared_ptr player; + _game.replaceRuntimeObjectGraph( + noObsolete, + [&]() { + player = _game.newCreature(); + player->setTag(kObjectTagPlayer); + player->setName(_character.name); + player->setGender(_character.gender); + player->setAppearance(_character.appearance); + player->loadAppearance(); + player->setFaction(Faction::Friendly1); + player->setImmortal(true); + player->attributes() = _character.attributes; + player->initializeGeneratedVitality(); + + player->setOnHeartbeat("k_hen_heartbt01"); + player->setOnSpawn("k_hen_spawn01"); + player->setOnNotice("k_hen_percept01"); + player->setOnEndRound("k_hen_combend01"); + player->setOnAttacked("k_hen_attacked01"); + player->setOnDamaged("k_hen_damage01"); + player->setOnBlocked("k_hen_blocked01"); + player->setOnDialogue("k_hen_dialogue01"); + }, + []() noexcept {}); Party &party = _game.party(); party.reset(); + party.initializeNewGameState(); party.addMember(kNpcPlayer, player); party.setPlayer(player); // The canonical PC has to exist before any script hands control to a @@ -421,7 +432,8 @@ void CharacterGeneration::reloadCharacterModel() { } std::shared_ptr CharacterGeneration::getCharacterModel(ISceneGraph &sceneGraph) { - std::shared_ptr creature = _game.newCreature(sceneGraph.name()); + std::shared_ptr creature = + _game.newPresentationCreature(sceneGraph.name()); creature->setFacing(-glm::half_pi()); creature->setAppearance(_character.appearance); creature->equip("g_a_clothes01"); @@ -438,7 +450,8 @@ void CharacterGeneration::updateAttributes() { std::shared_ptr clazz(_services.game.classes.get(_character.attributes.getEffectiveClass())); _controls.LBL_CLASS->setTextMessage(clazz->name()); - int vitality = clazz->hitdie() + _character.attributes.getAbilityModifier(Ability::Constitution); + int vitality = _character.attributes.getPermanentMaxHitPoints( + _character.attributes.getAggregateHitDie()); _controls.LBL_VIT->setTextMessage(std::to_string(vitality)); int defense = _character.attributes.getDefense(); diff --git a/src/libs/game/gui/chargen/classselect.cpp b/src/libs/game/gui/chargen/classselect.cpp index d33748ed3..1cfb00fd4 100644 --- a/src/libs/game/gui/chargen/classselect.cpp +++ b/src/libs/game/gui/chargen/classselect.cpp @@ -194,7 +194,8 @@ int ClassSelection::getRandomCharacterAppearance(Gender gender, ClassType clazz) } std::shared_ptr ClassSelection::getCharacterModel(int appearance, ISceneGraph &sceneGraph) { - std::shared_ptr character = _game.newCreature(sceneGraph.name()); + std::shared_ptr character = + _game.newPresentationCreature(sceneGraph.name()); character->setFacing(-glm::half_pi()); character->setAppearance(appearance); character->equip("g_a_clothes01"); diff --git a/src/libs/game/gui/chargen/portraitselect.cpp b/src/libs/game/gui/chargen/portraitselect.cpp index 483371e52..984fee33e 100644 --- a/src/libs/game/gui/chargen/portraitselect.cpp +++ b/src/libs/game/gui/chargen/portraitselect.cpp @@ -112,7 +112,8 @@ void PortraitSelection::loadHeadModel() { std::shared_ptr PortraitSelection::getCharacterModel(ISceneGraph &sceneGraph) { // Create a creature from the current portrait - std::shared_ptr creature = _game.newCreature(sceneGraph.name()); + std::shared_ptr creature = + _game.newPresentationCreature(sceneGraph.name()); creature->setFacing(-glm::half_pi()); creature->setAppearance(getAppearanceFromCurrentPortrait()); creature->equip("g_a_clothes01"); diff --git a/src/libs/game/gui/container.cpp b/src/libs/game/gui/container.cpp index 2e41216ff..01855ac23 100644 --- a/src/libs/game/gui/container.cpp +++ b/src/libs/game/gui/container.cpp @@ -20,6 +20,7 @@ #include "reone/gui/control/imagebutton.h" #include "reone/resource/provider/textures.h" #include "reone/resource/strings.h" +#include "reone/system/exception/validation.h" #include "reone/game/di/services.h" #include "reone/game/game.h" @@ -110,16 +111,24 @@ void ContainerGUI::populateItems(Object &source, bool onlyDropable, bool skipCre void ContainerGUI::open(std::shared_ptr container) { _controls.BTN_GIVEITEMS->setTextMessage(_giveItemMsg); - _container = std::move(container); + _container = container; _mode = Mode::ContainerToPlayer; - populateItems(*_container, /*onlyDropable=*/true, /*skipCredits=*/false); + populateItems(*container, /*onlyDropable=*/true, /*skipCredits=*/false); - auto placeable = dyn_cast(_container); + auto placeable = dyn_cast(container); if (placeable) { placeable->onOpen(_game.party().getLeader()->id()); } } +Object &ContainerGUI::container() const { + auto container = _container.resolve(); + if (!container) { + throw ValidationException("Container is no longer a live runtime object"); + } + return *container; +} + void ContainerGUI::close() { _game.openInGame(); } @@ -135,17 +144,27 @@ void ContainerGUI::switchMode() { case Mode::PlayerToContainer: { _mode = Mode::ContainerToPlayer; _controls.BTN_GIVEITEMS->setTextMessage(_giveItemMsg); - populateItems(*_container, /*onlyDropable=*/true, /*skipCredits=*/false); + auto container = _container.resolve(); + if (!container) { + close(); + return; + } + populateItems(*container, /*onlyDropable=*/true, /*skipCredits=*/false); break; } } } void ContainerGUI::transferItemsToPlayer() { + auto container = _container.resolve(); + if (!container) { + close(); + return; + } std::shared_ptr player = _game.party().player(); - _container->moveDropableItemsTo(*player); + container->moveDropableItemsTo(*player); - auto placeable = dyn_cast(_container); + auto placeable = dyn_cast(container); if (placeable) { placeable->runOnInvDisturbed(player->id(), InventoryDisturbType::Removed, script::kObjectInvalid); } @@ -158,6 +177,11 @@ void ContainerGUI::onItemDoubleClick(const std::string &tag) { // Do nothing for the player for now. return; } + auto container = _container.resolve(); + if (!container) { + close(); + return; + } std::shared_ptr player = _game.party().player(); std::shared_ptr item = player->getItemByTag(tag); @@ -170,21 +194,23 @@ void ContainerGUI::onItemDoubleClick(const std::string &tag) { // Add item to the container if it does not exist. uint32_t itemId = script::kObjectInvalid; - for (const std::shared_ptr &containerItem : _container->items()) { + for (const std::shared_ptr &containerItem : container->items()) { if (containerItem->tag() == item->tag()) { - _container->addItem(containerItem); + container->addItem(containerItem); itemId = containerItem->id(); } } if (itemId == script::kObjectInvalid) { // No existing item in the container. Clone the item instead of // adding it directly. - std::shared_ptr newItem = _game.newItem(); - newItem->clone(*item); + std::shared_ptr newItem = _game.newItemClone(*item); newItem->setStackSize(1); - _container->addItem(newItem); + container->addItem(newItem); itemId = newItem->id(); } + if (last) { + _game.destroyRuntimeObjectGraph(item); + } // Repopulate the list after the number of items changes. int offset = _controls.LB_ITEMS->getItemOffset(); @@ -195,7 +221,7 @@ void ContainerGUI::onItemDoubleClick(const std::string &tag) { // Execute a script for every item individually, because it only // supports a single InventoryDisturbItem. - auto placeable = dyn_cast(_container); + auto placeable = dyn_cast(container); if (placeable) { placeable->runOnInvDisturbed(player->id(), InventoryDisturbType::Added, itemId); } diff --git a/src/libs/game/gui/conversation.cpp b/src/libs/game/gui/conversation.cpp index 1e80a0245..d1b681206 100644 --- a/src/libs/game/gui/conversation.cpp +++ b/src/libs/game/gui/conversation.cpp @@ -78,8 +78,8 @@ static std::vector makeScriptArgs(uint32_t callerId, const Par void Conversation::start(const std::shared_ptr &dialog, const std::shared_ptr &owner) { if (_dialog) { onFinish(); - if (_owner) { - _owner->setIsInConversation(false); + if (auto oldOwner = _owner.resolve()) { + oldOwner->setIsInConversation(false); } } debug("Start " + dialog->resRef, LogChannel::Conversation); @@ -88,8 +88,8 @@ void Conversation::start(const std::shared_ptr &dialog, const std::share _dialog = dialog; _owner = owner; - if (_owner) { - _owner->setIsInConversation(true); + if (owner) { + owner->setIsInConversation(true); } loadConversationBackground(); @@ -174,12 +174,18 @@ bool Conversation::isLinkActive(const Dialog::EntryReplyLink &link) { } bool Conversation::evaluateCondition(const std::string &scriptResRef, const Dialog::EntryReplyLink::ConditionParams ¶ms) { - return _game.scriptRunner().run(scriptResRef, makeScriptArgs(_owner ? _owner->id() : 0, params)) != 0; + auto owner = _owner.resolve(); + return _game.scriptRunner().run( + scriptResRef, + makeScriptArgs(owner ? owner->id() : 0, params)) != 0; } void Conversation::runScript(const std::string &scriptResRef, const Dialog::EntryReply::ActionParams ¶ms) { if (!scriptResRef.empty()) { - _game.scriptRunner().run(scriptResRef, makeScriptArgs(_owner ? _owner->id() : 0, params)); + auto owner = _owner.resolve(); + _game.scriptRunner().run( + scriptResRef, + makeScriptArgs(owner ? owner->id() : 0, params)); } } @@ -207,12 +213,13 @@ void Conversation::finish() { } // Run EndConversation script - if (!_dialog->endScript.empty()) { - _game.scriptRunner().run(_dialog->endScript, _owner->id()); + auto owner = _owner.resolve(); + if (!_dialog->endScript.empty() && owner) { + _game.scriptRunner().run(_dialog->endScript, owner->id()); } - if (_owner) { - _owner->setIsInConversation(false); + if (owner) { + owner->setIsInConversation(false); } } @@ -230,8 +237,8 @@ void Conversation::cleanupForModuleTransition() { } _lipAnimation.reset(); onFinish(); - if (_owner) { - _owner->setIsInConversation(false); + if (auto owner = _owner.resolve()) { + owner->setIsInConversation(false); } } @@ -496,6 +503,10 @@ bool Conversation::handleKeyUp(const input::KeyEvent &event) { } void Conversation::update(float dt) { + if (_dialog && !_owner.empty() && !_owner.resolve()) { + finish(); + return; + } GameGUI::update(dt); if (!_entryEnded) { _endEntryTimer.update(dt); diff --git a/src/libs/game/gui/dialog.cpp b/src/libs/game/gui/dialog.cpp index 50a21ccda..a8fb8e41e 100644 --- a/src/libs/game/gui/dialog.cpp +++ b/src/libs/game/gui/dialog.cpp @@ -204,7 +204,7 @@ void DialogGUI::configureReplies() { } void DialogGUI::onStart() { - _currentSpeaker = _owner; + _currentSpeaker = owner(); _heldCutParticipants.clear(); loadStuntParticipants(); @@ -237,7 +237,7 @@ void DialogGUI::loadStuntParticipants() { if (_dialog->isAnimatedCutscene()) { creature->startStuntMode(); - participant.creature->setIsInConversation(true); + creature->setIsInConversation(true); } _participantByTag.insert(std::make_pair(stunt.participant, std::move(participant))); @@ -250,7 +250,7 @@ bool DialogGUI::hasStuntPresentation() const { std::shared_ptr DialogGUI::resolveParticipantCreature(const std::string &participant) const { if (participant == kObjectTagOwner) { - return std::dynamic_pointer_cast(_owner); + return std::dynamic_pointer_cast(owner()); } if (boost::iequals(participant, kObjectTagPlayer)) { return _game.party().player(); @@ -303,25 +303,30 @@ void DialogGUI::restoreInactiveStuntParticipants() { } bool DialogGUI::enterMixedStunt(Participant &participant, const std::shared_ptr &animation, bool looping) { - if (!participant.mixedStuntActive && participant.creature->isStuntMode()) { - warn("Dialog: participant is already in stunt mode: " + participant.creature->tag()); + auto creature = participant.creature.resolve(); + if (!creature) { + participant.mixedStuntActive = false; + return false; + } + if (!participant.mixedStuntActive && creature->isStuntMode()) { + warn("Dialog: participant is already in stunt mode: " + creature->tag()); return false; } AnimationProperties properties; properties.flags = AnimationFlags::propagate | (looping ? AnimationFlags::loop : 0); properties.scale = 1.0f; - if (!participant.creature->playExternalAnimation(animation, std::move(properties))) { + if (!creature->playExternalAnimation(animation, std::move(properties))) { return false; } if (!participant.mixedStuntActive) { - participant.restorePosition = participant.creature->position(); - participant.restoreFacing = participant.creature->getFacing(); - if (auto node = participant.creature->sceneNode()) { + participant.restorePosition = creature->position(); + participant.restoreFacing = creature->getFacing(); + if (auto node = creature->sceneNode()) { participant.restoreCulling = node->isCullingEnabled(); } - participant.creature->startStuntMode(); + creature->startStuntMode(); participant.mixedStuntActive = true; } return true; @@ -331,11 +336,16 @@ void DialogGUI::leaveMixedStunt(Participant &participant) { if (!participant.mixedStuntActive) { return; } - participant.creature->resumeStateDrivenAnimation(); - participant.creature->setPosition(participant.restorePosition); - participant.creature->setFacing(participant.restoreFacing); - participant.creature->stopStuntMode(); - if (auto node = participant.creature->sceneNode()) { + auto creature = participant.creature.resolve(); + if (!creature) { + participant.mixedStuntActive = false; + return; + } + creature->resumeStateDrivenAnimation(); + creature->setPosition(participant.restorePosition); + creature->setFacing(participant.restoreFacing); + creature->stopStuntMode(); + if (auto node = creature->sceneNode()) { node->setCullingEnabled(participant.restoreCulling); } participant.mixedStuntActive = false; @@ -349,12 +359,14 @@ void DialogGUI::loadCurrentSpeaker() { speaker = area->getObjectByTag(_currentEntry->speaker); } if (!speaker) { - speaker = _owner; + speaker = owner(); } // Make previous speaker stop talking, if any - if (_currentSpeaker && _currentSpeaker != speaker) { - auto speakerCreature = std::dynamic_pointer_cast(_currentSpeaker); + auto previousSpeaker = _currentSpeaker.resolve(); + if (previousSpeaker && previousSpeaker != speaker) { + auto speakerCreature = + std::dynamic_pointer_cast(previousSpeaker); if (speakerCreature) { speakerCreature->stopTalking(); } @@ -362,11 +374,11 @@ void DialogGUI::loadCurrentSpeaker() { _currentSpeaker = speaker; // Make current speaker face the player, and vice versa - if (_currentSpeaker) { + if (speaker) { std::shared_ptr player(_game.party().player()); - player->face(*_currentSpeaker); + player->face(*speaker); - auto speakerCreature = std::dynamic_pointer_cast(_currentSpeaker); + auto speakerCreature = std::dynamic_pointer_cast(speaker); if (speakerCreature) { speakerCreature->startTalking(_lipAnimation); speakerCreature->face(*player); @@ -380,7 +392,9 @@ void DialogGUI::updateCamera() { if (_dialog->cameraModel.empty()) { std::shared_ptr player(_game.party().player()); glm::vec3 listenerPosition(player ? getTalkPosition(*player) : glm::vec3(0.0f)); - glm::vec3 speakerPosition(_currentSpeaker ? getTalkPosition(*_currentSpeaker) : glm::vec3(0.0f)); + auto speaker = _currentSpeaker.resolve(); + glm::vec3 speakerPosition( + speaker ? getTalkPosition(*speaker) : glm::vec3(0.0f)); auto camera = area->getCamera(CameraType::Dialog); camera->setListenerPosition(listenerPosition); camera->setSpeakerPosition(speakerPosition); @@ -441,7 +455,10 @@ void DialogGUI::applyCutAnimation(const std::string &participant, const CutAnima AnimationProperties properties; properties.flags = AnimationFlags::propagate | (cut.looping ? AnimationFlags::loop : 0); properties.scale = 1.0f; - stunt.creature->playExternalAnimation(animation, std::move(properties)); + if (auto creature = stunt.creature.resolve()) { + creature->playExternalAnimation( + animation, std::move(properties)); + } } else { enterMixedStunt(stunt, animation, cut.looping); } @@ -561,22 +578,29 @@ void DialogGUI::onFinish() { releaseHeldCutParticipants(); // Make current speaker stop talking, if any - auto speakerCreature = std::dynamic_pointer_cast(_currentSpeaker); + auto speakerCreature = + std::dynamic_pointer_cast(_currentSpeaker.resolve()); if (speakerCreature) { speakerCreature->stopTalking(); } } void DialogGUI::holdCutParticipant(const std::shared_ptr &creature) { - auto maybeHeld = std::find(_heldCutParticipants.begin(), _heldCutParticipants.end(), creature); + auto maybeHeld = std::find_if( + _heldCutParticipants.begin(), _heldCutParticipants.end(), + [&creature](const auto &held) { + return held.resolve() == creature; + }); if (maybeHeld == _heldCutParticipants.end()) { _heldCutParticipants.push_back(creature); } } void DialogGUI::releaseHeldCutParticipants() { - for (auto &creature : _heldCutParticipants) { - creature->resumeStateDrivenAnimation(); + for (auto &reference : _heldCutParticipants) { + if (auto creature = reference.resolve()) { + creature->resumeStateDrivenAnimation(); + } } _heldCutParticipants.clear(); } @@ -590,9 +614,11 @@ void DialogGUI::releaseStuntParticipants() { return; } for (auto &participant : _participantByTag) { - participant.second.creature->resumeStateDrivenAnimation(); - participant.second.creature->stopStuntMode(); - participant.second.creature->setIsInConversation(false); + auto creature = participant.second.creature.resolve(); + if (!creature) continue; + creature->resumeStateDrivenAnimation(); + creature->stopStuntMode(); + creature->setIsInConversation(false); } _participantByTag.clear(); } @@ -648,9 +674,10 @@ void DialogGUI::update(float dt) { Conversation::update(dt); // Dialog camera follows the current speaker, if any - if (_currentSpeaker && _game.cameraType() == CameraType::Dialog) { + auto speaker = _currentSpeaker.resolve(); + if (speaker && _game.cameraType() == CameraType::Dialog) { auto camera = _game.module()->area()->getCamera(CameraType::Dialog); - camera->setSpeakerPosition(getTalkPosition(*_currentSpeaker)); + camera->setSpeakerPosition(getTalkPosition(*speaker)); } } diff --git a/src/libs/game/gui/ingame.cpp b/src/libs/game/gui/ingame.cpp index 796c9444f..53ec70765 100644 --- a/src/libs/game/gui/ingame.cpp +++ b/src/libs/game/gui/ingame.cpp @@ -373,7 +373,7 @@ void InGameMenu::refreshK2Footer() { _controls.LBL_TOP_CLASS2LEVEL->setVisible(true); _controls.LBL_TOP_CLASS2LEVEL->setTextMessage(describeLevel(attributes.getLevelByPosition(2))); - int hitPoints = leader->hitPoints(); + int hitPoints = leader->maxHitPoints(); int vitalityPercent = hitPoints > 0 ? std::clamp(100 * leader->currentHitPoints() / hitPoints, 0, 100) : 0; diff --git a/src/libs/game/gui/ingame/character.cpp b/src/libs/game/gui/ingame/character.cpp index f350a569d..d8c75931d 100644 --- a/src/libs/game/gui/ingame/character.cpp +++ b/src/libs/game/gui/ingame/character.cpp @@ -134,7 +134,7 @@ void CharacterMenu::refreshControls() { _controls.LBL_LEVEL2->setTextMessage(toStringOrEmptyIfZero(attributes.getLevelByPosition(2))); } - _controls.LBL_VITALITY_STAT->setTextMessage(str(boost::format("%d/%d") % partyLeader->currentHitPoints() % partyLeader->hitPoints())); + _controls.LBL_VITALITY_STAT->setTextMessage(str(boost::format("%d/%d") % partyLeader->currentHitPoints() % partyLeader->maxHitPoints())); _controls.LBL_DEFENSE_STAT->setTextMessage(std::to_string(partyLeader->getDefense())); _controls.LBL_FORCE_STAT->setTextMessage(""); @@ -203,15 +203,19 @@ void CharacterMenu::refresh3D() { std::shared_ptr CharacterMenu::getSceneModel(ISceneGraph &sceneGraph) const { auto partyLeader = _game.party().getLeader(); - std::shared_ptr character = _game.newCreature(sceneGraph.name()); + std::shared_ptr character = + _game.newPresentationCreature(sceneGraph.name()); character->setFacing(-glm::half_pi()); character->setAppearance(partyLeader->appearance()); for (auto &item : partyLeader->equipment()) { switch (item.first) { - case InventorySlots::body: - character->equip(item.first, item.second); + case InventorySlots::body: { + auto previewItem = _game.newPresentationItem(); + previewItem->clone(*item.second); + character->equip(item.first, previewItem); break; + } default: break; } diff --git a/src/libs/game/gui/ingame/equip.cpp b/src/libs/game/gui/ingame/equip.cpp index 28bd5f4d4..052ca55f6 100644 --- a/src/libs/game/gui/ingame/equip.cpp +++ b/src/libs/game/gui/ingame/equip.cpp @@ -327,19 +327,26 @@ void Equipment::confirmCandidateItem(const std::string &item) { decision.action == EquipmentCandidateAction::ClearMainHandAndOffHand; bool equipmentChanged = equipped != itemObj || pairedOffHand; if (equipmentChanged) { - if (equipped) { - partyLeader->unequip(equipped); - player->addItem(equipped); - } - if (pairedOffHand) { - partyLeader->unequip(pairedOffHand); - player->addItem(pairedOffHand); - } if (itemObj) { auto candidate = takeEquipmentCandidate(_game, *player, itemObj); - if (candidate && !partyLeader->equip(slot, candidate)) { + if (!candidate) return; + if (pairedOffHand) { + partyLeader->moveEquippedItemTo(pairedOffHand, *player); + } + bool equippedCandidate = equipped + ? partyLeader->replaceEquipment( + slot, candidate, *player) + : partyLeader->equip(slot, candidate); + if (!equippedCandidate) { player->addItem(candidate); } + } else { + if (equipped) { + partyLeader->moveEquippedItemTo(equipped, *player); + } + if (pairedOffHand) { + partyLeader->moveEquippedItemTo(pairedOffHand, *player); + } } } if (equipmentChanged || clearAction) { @@ -360,7 +367,7 @@ void Equipment::update() { auto partyLeader(_game.party().getLeader()); if (!_game.isTSL()) { - std::string vitalityString(str(boost::format("%d/\n%d") % partyLeader->currentHitPoints() % partyLeader->hitPoints())); + std::string vitalityString(str(boost::format("%d/\n%d") % partyLeader->currentHitPoints() % partyLeader->maxHitPoints())); _controls.LBL_VITALITY->setTextMessage(vitalityString); } _controls.LBL_DEF->setTextMessage(std::to_string(partyLeader->getDefense())); diff --git a/src/libs/game/gui/ingame/inventory.cpp b/src/libs/game/gui/ingame/inventory.cpp index 71aaddaf3..b8a0b9db5 100644 --- a/src/libs/game/gui/ingame/inventory.cpp +++ b/src/libs/game/gui/ingame/inventory.cpp @@ -433,7 +433,7 @@ void InventoryMenu::refreshStats() { } if (_controls.LBL_VIT) { - _controls.LBL_VIT->setTextMessage(std::to_string(partyLeader->currentHitPoints()) + "/\n" + std::to_string(partyLeader->hitPoints())); + _controls.LBL_VIT->setTextMessage(std::to_string(partyLeader->currentHitPoints()) + "/\n" + std::to_string(partyLeader->maxHitPoints())); _controls.LBL_VIT->setVisible(true); } if (_controls.LBL_DEF) { diff --git a/src/libs/game/gui/partyselect.cpp b/src/libs/game/gui/partyselect.cpp index aaf45c216..ef72bbd57 100644 --- a/src/libs/game/gui/partyselect.cpp +++ b/src/libs/game/gui/partyselect.cpp @@ -57,19 +57,6 @@ static glm::vec3 g_kotorColorOn = {0.984314f, 1.0f, 0}; static glm::vec3 g_kotorColorAdded = {0, 0.831373f, 0.090196f}; static glm::vec3 g_tslColorAdded = {1.0f, 1.0f, 1.0f}; -static bool matchesPendingSelection( - const bool (&added)[kMaxNpcCount], - const bool (&baselineAdded)[kMaxNpcCount], - int npcCount) { - - for (int i = 0; i < npcCount; ++i) { - if (added[i] != baselineAdded[i]) { - return false; - } - } - return true; -} - PartySelection::PartySelection(Game &game, ServicesView &services) : GameGUI(game, services) { @@ -119,9 +106,13 @@ void PartySelection::bindEventHandlers() { onAcceptButtonClick(); }); _controls.BTN_DONE->setOnClick([this]() { - if (!matchesPendingSelection(_added, _baselineAdded, npcCount())) { - changeParty(); + std::vector selectedNpcs; + for (int npc = 0; npc < npcCount(); ++npc) { + if (_added[npc]) { + selectedNpcs.push_back(npc); + } } + _game.reconcilePartySelection(selectedNpcs); _game.openInGame(); if (!_context.exitScript.empty()) { _game.scriptRunner().run(_context.exitScript); @@ -184,7 +175,6 @@ void PartySelection::prepare(const PartySelectionContext &ctx) { for (int i = 0; i < kMaxNpcCount; ++i) { _added[i] = false; - _baselineAdded[i] = false; } for (int i = 0; i < npcCount(); ++i) { getNpcButton(i).setUseBorderColorOverride(false); @@ -197,9 +187,6 @@ void PartySelection::prepare(const PartySelectionContext &ctx) { addNpc(member.npc); } } - for (int i = 0; i < npcCount(); ++i) { - _baselineAdded[i] = _added[i]; - } if (ctx.forceNpc1 >= 0) { addNpc(ctx.forceNpc1); } @@ -241,7 +228,7 @@ void PartySelection::prepare(const PartySelectionContext &ctx) { Label &LBL_CHAR = *charLabels[i]; Label &LBL_NA = *naLabels[i]; - if (auto member = party.getAvailableMember(i)) { + if (auto member = party.getAvailableMember(i, true)) { auto portrait = member->portrait(); BTN_NPC.setDisabled(false); BTN_NPC.setVisible(true); @@ -365,34 +352,6 @@ void PartySelection::refreshNpcButtons() { } } -void PartySelection::changeParty() { - Party &party = _game.party(); - for (int i = 0; i < npcCount(); ++i) { - if (_added[i] && !party.isMemberAvailable(i)) { - warn("Party selection: NPC is not available: " + std::to_string(i)); - return; - } - } - - std::shared_ptr area(_game.module()->area()); - for (const auto &member : party.members()) { - if (member.npc != kNpcPlayer && !_added[member.npc]) { - area->unloadPartyMember(member.creature); - } - } - - party.clear(); - party.addMember(kNpcPlayer, party.player()); - - for (int i = 0; i < npcCount(); ++i) { - if (!_added[i]) - continue; - party.addMember(i, party.getAvailableMember(i)); - } - - area->reloadParty(); -} - } // namespace game } // namespace reone diff --git a/src/libs/game/gui/selectoverlay.cpp b/src/libs/game/gui/selectoverlay.cpp index d3b401261..66312104c 100644 --- a/src/libs/game/gui/selectoverlay.cpp +++ b/src/libs/game/gui/selectoverlay.cpp @@ -385,7 +385,7 @@ void SelectionOverlay::renderHealthBar() { float healthBarHeight = kHealthBarHeight * titleScale; float x = opts.width * _selectedScreenCoords.x - barWidth / 2; float y = opts.height * (1.0f - _selectedScreenCoords.y) - _reticleHeight * scale / 2.0f - healthBarHeight - kOffsetToReticle * scale; - float w = glm::clamp(_selectedObject->currentHitPoints() / static_cast(_selectedObject->hitPoints()), 0.0f, 1.0f) * barWidth; + float w = glm::clamp(_selectedObject->currentHitPoints() / static_cast(_selectedObject->maxHitPoints()), 0.0f, 1.0f) * barWidth; if (_hasActions) { y -= (kActionHeight + 2 * kActionBarMargin) * scale; diff --git a/src/libs/game/messagebus.cpp b/src/libs/game/messagebus.cpp index 51d9caf59..1d23c0486 100644 --- a/src/libs/game/messagebus.cpp +++ b/src/libs/game/messagebus.cpp @@ -17,20 +17,30 @@ #include "reone/game/messagebus.h" +#include + #include "reone/game/object/creature.h" namespace reone { namespace game { -void MessageBus::addListener(uint32_t listenerId, std::string pattern, int32_t number) { +void MessageBus::addListener( + const std::shared_ptr &listener, + std::string pattern, + int32_t number) { + if (!listener || !listener->isRuntimeLive()) { + return; + } + + pruneDeadListeners(); ListenerVec &vec = _listeners[pattern]; - for (Listener &listener : vec) { - if (listenerId == listener.id) { - listener.number = number; + for (Listener &entry : vec) { + if (entry.object.resolve() == listener) { + entry.number = number; return; } } - vec.push_back({listenerId, number}); + vec.push_back({RuntimeObjectRef(listener), number}); } void MessageBus::addMessage(uint32_t speakerId, std::string pattern, TalkVolume volume) { @@ -38,18 +48,57 @@ void MessageBus::addMessage(uint32_t speakerId, std::string pattern, TalkVolume } void MessageBus::update(OnMessage onMessage) { + pruneDeadListeners(); while (!_pendingMessages.empty()) { - Message &msg = _pendingMessages.front(); + Message msg = std::move(_pendingMessages.front()); + _pendingMessages.pop(); // Pattern may be a regexp (** for a sequence of any characters, *n for numbers, etc.) // KOTOR does not seem to have these yet, so we only match the whole string. - ListenerVec &vec = _listeners[msg.str]; + auto found = _listeners.find(msg.str); + if (found == _listeners.end()) { + continue; + } - for (Listener &listener : vec) { - onMessage(msg.speakerId, listener.id, listener.number, msg.volume); + // Scripts may add listeners or destroy this or another listener while + // handling a message. Iterate a snapshot so map/vector mutation cannot + // invalidate dispatch, and resolve every entry immediately before use. + const ListenerVec listeners = found->second; + for (const Listener &entry : listeners) { + auto listener = entry.object.resolve(); + if (!listener) { + continue; + } + onMessage(msg.speakerId, listener, entry.number, msg.volume); + } + pruneDeadListeners(); + } +} + +size_t MessageBus::listenerCount() const { + size_t result = 0; + for (const auto &[_, listeners] : _listeners) { + result += listeners.size(); + } + return result; +} + +void MessageBus::pruneDeadListeners() { + for (auto found = _listeners.begin(); found != _listeners.end();) { + auto &listeners = found->second; + listeners.erase( + std::remove_if( + listeners.begin(), listeners.end(), + [](const Listener &entry) { + return entry.object.resolve() == nullptr; + }), + listeners.end()); + if (listeners.empty()) { + found = _listeners.erase(found); + } else { + ++found; } - _pendingMessages.pop(); } } diff --git a/src/libs/game/modulesnapshot.cpp b/src/libs/game/modulesnapshot.cpp index 2cb1493c4..33a8ed4f6 100644 --- a/src/libs/game/modulesnapshot.cpp +++ b/src/libs/game/modulesnapshot.cpp @@ -59,6 +59,14 @@ using resource::ResType; constexpr uint32_t kNcsHeaderSize = 13; +template +struct Overloaded : Visitors... { + using Visitors::operator()...; +}; + +template +Overloaded(Visitors...) -> Overloaded; + std::shared_ptr emptyRecord(uint32_t type) { return Gff::Builder().type(type).build(); } @@ -396,13 +404,33 @@ std::shared_ptr eventToGff( else if (auto value = std::get_if(&event.payload)) data = effectToGff(*value, game); else if (auto value = std::get_if(&event.payload)) data = Gff::Builder().type(0x9999).field(Gff::Field::newByte("Value", value->value)).build(); else if (auto value = std::get_if(&event.payload)) data = Gff::Builder().type(0x3333).field(Gff::Field::newInt("Value", value->value)).build(); - else if (auto value = std::get_if(&event.payload)) data = Gff::Builder().type(0x3333).field(Gff::Field::newDword("Value", value->value)).build(); else if (auto value = std::get_if(&event.payload)) data = scriptEventToGff(*value); else if (auto value = std::get_if(&event.payload)) data = Gff::Builder().type(0x5555) .field(Gff::Field::newDword("BodyBagId", value->object.id)) .field(Gff::Field::newFloat("PositionX", value->position.x)) .field(Gff::Field::newFloat("PositionY", value->position.y)) .field(Gff::Field::newFloat("PositionZ", value->position.z)).build(); + else if (auto value = std::get_if(&event.payload)) data = Gff::Builder().type(0x3333) + .field(Gff::Field::newDword("Value", value->target.id)).build(); + else if (auto value = std::get_if(&event.payload)) { + data = savedStructToGff(value->data); + put(*data, Gff::Field::newDword( + "ReactObject", value->reactionObject.id)); + put(*data, Gff::Field::newDword("AmmoItem", value->ammoItem.id)); + } else if (auto value = std::get_if(&event.payload)) { + data = savedStructToGff(value->data); + auto objects = data->getList("ObjectIDList"); + if (objects.size() != value->objects.size()) { + throw ValidationException( + "feedback message object-reference shadow changed shape"); + } + for (size_t index = 0; index < objects.size(); ++index) { + put(*objects[index], Gff::Field::newDword( + "ObjectValue", value->objects[index].id)); + } + put(*data, Gff::Field::newList( + "ObjectIDList", std::move(objects))); + } else if (auto value = std::get_if(&event.payload)) data = Gff::Builder().type(0x6666) .field(Gff::Field::newInt("SpellId", value->spellId)) .field(Gff::Field::newDword("CasterId", value->caster.id)) @@ -432,59 +460,88 @@ void putTransform(Gff &record, const Object &object, bool xyzLabels = false) { } // namespace -void ModuleObjectIdContext::reserveWorldId(uint32_t id) { - if (id >= kSavedRuntimeInvalidObjectId) { - throw ValidationException("ordinary saved object uses a reserved ID"); - } - if (!_used.insert(id).second) { - throw ValidationException("saved object ID collides in module namespace"); - } +static bool sameModuleIdentityNamespace( + const SerializedIdentityContext &lhs, + const SerializedIdentityContext &rhs) { + return lhs.hasAuthoritativeObjectIds() && + rhs.hasAuthoritativeObjectIds() && + boost::iequals(lhs.identityNamespace, rhs.identityNamespace); } -void ModuleObjectIdContext::reservePartyId(uint32_t id) { - if (id < kSavedRuntimeInvalidObjectId) { - reserveWorldId(id); +void ModuleObjectIdContext::assignContextObject( + const Object &object, uint32_t id) { + if (_objectIds.count(&object) != 0) { return; } - if (id == kSavedRuntimeInvalidObjectId || id > 0x7fffffffu || - !_reservedPartyIds.insert(id).second) { - throw ValidationException("saved party creature uses an invalid or duplicate reserved ID"); + if (id >= kSavedRuntimeInvalidObjectId || !_used.insert(id).second) { + throw ValidationException( + "contextual object ID collides in module namespace"); } + _objectIds.emplace(&object, id); } -void ModuleObjectIdContext::retainItem(const Item &item) { - if (_itemIds.count(&item) != 0) return; - constexpr uint32_t invalid = std::numeric_limits::max(); - auto preferred = item.originalOwnerLocalObjectId(); - if (!preferred || *preferred >= kSavedRuntimeInvalidObjectId || - *preferred == invalid) { +void ModuleObjectIdContext::retainObject( + const Object &object, bool partyIdentity) { + if (_objectIds.count(&object) != 0) return; + auto identity = object.serializedObjectIdentity(); + if (!identity || + !sameModuleIdentityNamespace(identity->context, _outputIdentityContext)) { return; } - if (!_used.insert(*preferred).second) { - throw ValidationException("retained module item ID collides in saved object namespace"); + uint32_t id = identity->id; + if (partyIdentity && id >= kSavedRuntimeInvalidObjectId) { + if (id > 0x7fffffffu || !_reservedPartyIds.insert(id).second) { + throw ValidationException( + "saved party creature uses an invalid or duplicate reserved ID"); + } + } else { + if (id >= kSavedRuntimeInvalidObjectId) { + throw ValidationException("ordinary saved object uses a reserved ID"); + } + if (!_used.insert(id).second) { + throw ValidationException( + "authoritative saved object ID collides in module namespace"); + } } - _itemIds.emplace(&item, *preferred); + _objectIds.emplace(&object, id); } -void ModuleObjectIdContext::allocateItem(const Item &item) { - if (_itemIds.count(&item) != 0) return; +void ModuleObjectIdContext::allocateObject( + const Object &object, bool partyIdentity) { + if (_objectIds.count(&object) != 0) return; + const uint32_t preferred = object.id(); + if (partyIdentity && preferred > kSavedRuntimeInvalidObjectId && + preferred <= 0x7fffffffu && + _reservedPartyIds.insert(preferred).second) { + _objectIds.emplace(&object, preferred); + return; + } + if (preferred < kSavedRuntimeInvalidObjectId && + _used.insert(preferred).second) { + _objectIds.emplace(&object, preferred); + return; + } for (uint32_t candidate = 2; candidate < kSavedRuntimeInvalidObjectId; ++candidate) { if (_used.insert(candidate).second) { - _itemIds.emplace(&item, candidate); + _objectIds.emplace(&object, candidate); return; } } throw ValidationException("saved object ID namespace is exhausted"); } -uint32_t ModuleObjectIdContext::itemId(const Item &item) const { - auto found = _itemIds.find(&item); - if (found == _itemIds.end()) { - throw ValidationException("module item has no serialized object ID"); +uint32_t ModuleObjectIdContext::objectId(const Object &object) const { + auto found = _objectIds.find(&object); + if (found == _objectIds.end()) { + throw ValidationException("module graph object has no serialized identity"); } return found->second; } +bool ModuleObjectIdContext::contains(const Object &object) const { + return _objectIds.count(&object) != 0; +} + uint32_t ModuleObjectIdContext::nextId(uint32_t retainedCursor) const { uint32_t next = 2; if (!_used.empty()) { @@ -504,19 +561,21 @@ uint32_t ModuleObjectIdContext::nextId(uint32_t retainedCursor) const { ModuleObjectIdContext ModuleSnapshotBuilder::buildObjectIdContext( const Module &module, const Area &area) const { - ModuleObjectIdContext ids; - // Items owned by this module keep the saved identity they already hold in - // this module's namespace. Items carried by the party arrived from whatever - // module they were last serialized in, so their saved IDs mean nothing - // here: retail allocates party item IDs inside each module's own namespace, - // and retaining a foreign one collides with a legitimate world object as - // soon as the two ranges overlap. - std::vector moduleItems; - std::vector partyItems; - std::set seenItems; - std::vector *sink = &moduleItems; + ModuleObjectIdContext ids( + SerializedIdentityContext::moduleGraph(_saveGroup)); + // Retail creates the structural Module before loading the owned IFO/GIT + // graph. Slot 0 is consequently a contextual reference target, not an + // ObjectId persisted by an owned Module record. + ids.assignContextObject(module, kSavedRuntimeModuleObjectId); + std::vector> objects; + std::set seen; + auto addObject = [&](const Object &object, bool partyIdentity = false) { + if (seen.insert(&object).second) { + objects.emplace_back(&object, partyIdentity); + } + }; auto addItem = [&](const std::shared_ptr &item) { - if (item && seenItems.insert(item.get()).second) sink->push_back(item.get()); + if (item) addObject(*item); }; auto addCreatureItems = [&](const Creature &creature, bool includeInventory) { std::set equipped; @@ -532,12 +591,15 @@ ModuleObjectIdContext ModuleSnapshotBuilder::buildObjectIdContext( } }; - ids.reserveWorldId(area._id); + addObject(module); + addObject(area); for (const auto &object : area._objects) { - if (!object || area._objectsToDestroy.count(object->id()) != 0 || - _game._party.isMember(*object)) continue; + if (!object || area._objectsToDestroy.count(object->id()) != 0) continue; + if (object->type() == ObjectType::Creature && + _game._party.isRetainedRuntimeRepresentation( + *static_cast(object.get()))) continue; if (ownsSavedObjectIdentity(object->type())) { - ids.reserveWorldId(object->id()); + addObject(*object); } switch (object->type()) { case ObjectType::Creature: @@ -555,21 +617,22 @@ ModuleObjectIdContext ModuleSnapshotBuilder::buildObjectIdContext( } auto modulePlayer = _game._party.player(); if (!modulePlayer) throw ValidationException("module has no controlled player creature"); - ids.reservePartyId(modulePlayer->id()); - sink = &partyItems; + addObject(*modulePlayer, true); addCreatureItems(*modulePlayer, false); - sink = &moduleItems; for (const auto &creature : module._limboCreatures) { if (!creature) continue; - ids.reservePartyId(creature->id()); + addObject(*creature, true); addCreatureItems(*creature, true); } - // Retain only what this module already owns, then allocate the rest. Party - // items are allocated after every retained ID is reserved, so they can - // never displace a legitimate module identity. - for (const Item *item : moduleItems) ids.retainItem(*item); - for (const Item *item : moduleItems) ids.allocateItem(*item); - for (const Item *item : partyItems) ids.allocateItem(*item); + // Reserve every identity already authoritative in this exact destination + // graph before importing anything. Detached/local numbers never enter the + // namespace; allocation is keyed by the live object, not by that number. + for (const auto &[object, partyIdentity] : objects) { + ids.retainObject(*object, partyIdentity); + } + for (const auto &[object, partyIdentity] : objects) { + ids.allocateObject(*object, partyIdentity); + } return ids; } @@ -618,11 +681,18 @@ std::optional exportScriptSituation( case script::VariableType::String: saved.type = static_cast(SavedVmStackType::String); saved.payload = value.strValue; break; case script::VariableType::Object: - saved.type = static_cast(SavedVmStackType::Object); saved.payload = SavedObjectReference(value.objectId); break; + saved.type = static_cast(SavedVmStackType::Object); saved.payload = SavedObjectReference::fromRuntimeId(value.objectId); break; case script::VariableType::Effect: { auto effect = std::dynamic_pointer_cast(value.engineType); - if (!effect) { error = "live VM effect lacks a save-facing EffectInstance"; return std::nullopt; } - saved.type = static_cast(SavedVmStackType::Effect); saved.payload = effect->instance(); break; + auto liveEffect = std::dynamic_pointer_cast(value.engineType); + if (!liveEffect) { + error = "live VM effect has an unsupported engine value"; + return std::nullopt; + } + saved.type = static_cast(SavedVmStackType::Effect); + saved.payload = effect ? effect->instance() + : liveEffect->saveFacingInstance(); + break; } case script::VariableType::Event: { auto event = std::dynamic_pointer_cast(value.engineType); @@ -661,7 +731,7 @@ std::optional exportScriptSituation( talent->value(), static_cast(talent->type()), talent->multiClass(), - SavedObjectReference {talent->item()}, + SavedObjectReference::fromRuntimeId(talent->item()), talent->itemPropertyIndex(), talent->casterLevel(), talent->metaType()}; @@ -710,8 +780,14 @@ std::shared_ptr ModuleSnapshotBuilder::objectBase( } void ModuleSnapshotBuilder::writeObjectState( - Gff &record, const Object &object) const { - put(record, Gff::Field::newDword("ObjectId", object._id)); + Gff &record, + const Object &object, + const ModuleObjectIdContext &ids) const { + if (ids.contains(object)) { + put(record, Gff::Field::newDword("ObjectId", ids.objectId(object))); + } else { + removeSaveField(record, "ObjectId"); + } put(record, Gff::Field::newCExoString("Tag", object._tag)); if (!object._blueprintResRef.empty()) { put(record, Gff::Field::newResRef("TemplateResRef", object._blueprintResRef)); @@ -724,13 +800,13 @@ void ModuleSnapshotBuilder::writeObjectState( std::vector> effects; for (auto effect : object.saveEffectSnapshot()) { effects.push_back(effectToGff( - normalizeEffectReferences(std::move(effect)), &_game)); + normalizeEffectReferences(std::move(effect), ids), &_game)); } put(record, Gff::Field::newList("EffectList", std::move(effects))); std::vector> actions; for (auto action : object.saveActionSnapshot()) { - normalizeActionReferences(action); + normalizeActionReferences(action, ids); actions.push_back(actionToGff(action, &_game)); } put(record, Gff::Field::newList("ActionList", std::move(actions))); @@ -744,123 +820,169 @@ void ModuleSnapshotBuilder::writeObjectState( auto bound = object.savedReference(field); put(record, Gff::Field::newDword( field, - bound && isSerializedWorldObject(*bound) - ? bound->id() + bound && ids.contains(*bound) + ? ids.objectId(*bound) : kSavedRuntimeInvalidObjectId)); } } -} -bool ModuleSnapshotBuilder::isSerializedWorldObject( - const Object &object) const { - if (!_game._module || !_game._module->_area) return false; - if (&object == _game._module.get()) return true; - const auto &area = *_game._module->_area; - if (&object == &area) return true; - for (const auto &entry : area._objects) { - if (entry.get() == &object && - area._objectsToDestroy.count(object.id()) == 0) return true; - } - for (const auto &entry : _game._module->_limboCreatures) { - if (entry.get() == &object) return true; - } - return _game._party.player().get() == &object || - _game._party.actualPlayer().get() == &object; + // PerceptionList is part of the same reference-bearing save shadow as the + // flat fields above. Rewrite every known ObjectId before a subclass either + // publishes live perception state or preserves the record as shadow data; + // otherwise graph renumbering could leave a stale numeric alias behind. + std::vector> perceptions; + size_t perceptionIndex = 0; + for (const auto &savedPerception : record.getList("PerceptionList")) { + auto perception = savedPerception->deepCopy(); + auto binding = object.savedReference( + "Perception/" + std::to_string(perceptionIndex)); + put(*perception, Gff::Field::newDword( + "ObjectId", + binding && ids.contains(*binding) + ? ids.objectId(*binding) + : kSavedRuntimeInvalidObjectId)); + perceptions.push_back(std::move(perception)); + ++perceptionIndex; + } + if (record.has("PerceptionList")) { + put(record, Gff::Field::newList( + "PerceptionList", std::move(perceptions))); + } } uint32_t ModuleSnapshotBuilder::serializedReferenceId( - const SavedObjectReference &reference) const { + const SavedObjectReference &reference, + const ModuleObjectIdContext &ids) const { if (reference.isInvalid()) return kSavedRuntimeInvalidObjectId; auto bound = reference.boundObject(); if (bound) { - return isSerializedWorldObject(*bound) - ? bound->id() + return ids.contains(*bound) + ? ids.objectId(*bound) : kSavedRuntimeInvalidObjectId; } + if (reference.isSerializedIdentity()) { + return kSavedRuntimeInvalidObjectId; + } auto found = _game._objectById.find(reference.id); - return found != _game._objectById.end() && - isSerializedWorldObject(*found->second) - ? found->second->id() + return found != _game._objectById.end() && ids.contains(*found->second) + ? ids.objectId(*found->second) : kSavedRuntimeInvalidObjectId; } EffectInstance ModuleSnapshotBuilder::normalizeEffectReferences( - EffectInstance effect) const { - auto normalizeId = [this](uint32_t id) { + EffectInstance effect, const ModuleObjectIdContext &ids) const { + auto normalizeId = [this, &ids, serialized = effect.hasSerializedObjectReferences()](uint32_t id) { if (id == kSavedRuntimeInvalidObjectId || id == kSavedEffectInvalidObjectId) return id; + if (serialized) return kSavedRuntimeInvalidObjectId; auto found = _game._objectById.find(id); - return found != _game._objectById.end() && - isSerializedWorldObject(*found->second) - ? found->second->id() + return found != _game._objectById.end() && ids.contains(*found->second) + ? ids.objectId(*found->second) : kSavedRuntimeInvalidObjectId; }; if (auto creator = effect.boundCreator()) { - effect.creatorId = isSerializedWorldObject(*creator) - ? creator->id() + effect.creatorId = ids.contains(*creator) + ? ids.objectId(*creator) : kSavedRuntimeInvalidObjectId; } else { effect.creatorId = normalizeId(effect.creatorId); } - for (auto &id : effect.objectParameters) id = normalizeId(id); + for (size_t index = 0; index < effect.objectParameters.size(); ++index) { + if (auto object = effect.boundObjectParameter(index)) { + effect.objectParameters[index] = ids.contains(*object) + ? ids.objectId(*object) + : kSavedRuntimeInvalidObjectId; + } else { + effect.objectParameters[index] = normalizeId( + effect.objectParameters[index]); + } + } return effect; } void ModuleSnapshotBuilder::normalizeSituationReferences( - SerializedScriptSituation &situation) const { + SerializedScriptSituation &situation, + const ModuleObjectIdContext &ids) const { for (auto &value : situation.stack) { - if (auto reference = std::get_if(&value.payload)) { - reference->id = serializedReferenceId(*reference); - } else if (auto effect = std::get_if(&value.payload)) { - *effect = normalizeEffectReferences(std::move(*effect)); - } else if (auto event = std::get_if(&value.payload)) { - for (auto &reference : event->objects) { - reference.id = serializedReferenceId(reference); - } - } + std::visit( + Overloaded { + [](UnsupportedSavedPayload &) {}, + [](int32_t &) {}, + [](float &) {}, + [](std::string &) {}, + [this, &ids](SavedObjectReference &reference) { + reference.id = serializedReferenceId(reference, ids); + }, + [this, &ids](EffectInstance &effect) { + effect = normalizeEffectReferences(std::move(effect), ids); + }, + [this, &ids](SavedScriptEvent &event) { + for (auto &reference : event.objects) { + reference.id = serializedReferenceId(reference, ids); + } + }, + [](SavedLocationValue &) {}, + [this, &ids](SavedTalentValue &talent) { + talent.item.id = serializedReferenceId(talent.item, ids); + }, + }, + value.payload); } } void ModuleSnapshotBuilder::normalizeActionReferences( - SavedActionRecord &action) const { + SavedActionRecord &action, + const ModuleObjectIdContext &ids) const { for (size_t index = 0; index < action.parameters.size(); ++index) { auto ¶meter = action.parameters[index]; // Retail stores PlayAnimation's animation identifier in its generic // type-3/DWORD slot. It is not an object reference. if (action.actionId == 6 && index == 0) continue; if (auto reference = std::get_if(¶meter.payload)) { - reference->id = serializedReferenceId(*reference); + reference->id = serializedReferenceId(*reference, ids); } else if (auto situation = std::get_if(¶meter.payload)) { - normalizeSituationReferences(*situation); + normalizeSituationReferences(*situation, ids); } } } void ModuleSnapshotBuilder::normalizeEventReferences( - SavedEventRecord &event) const { - event.object.id = serializedReferenceId(event.object); - event.caller.id = serializedReferenceId(event.caller); + SavedEventRecord &event, + const ModuleObjectIdContext &ids) const { + event.object.id = serializedReferenceId(event.object, ids); + event.caller.id = serializedReferenceId(event.caller, ids); if (auto situation = std::get_if(&event.payload)) { - normalizeSituationReferences(*situation); + normalizeSituationReferences(*situation, ids); } else if (auto effect = std::get_if(&event.payload)) { - *effect = normalizeEffectReferences(std::move(*effect)); + *effect = normalizeEffectReferences(std::move(*effect), ids); } else if (auto spell = std::get_if(&event.payload)) { - spell->caster.id = serializedReferenceId(spell->caster); - spell->target.id = serializedReferenceId(spell->target); - spell->area.id = serializedReferenceId(spell->area); - spell->item.id = serializedReferenceId(spell->item); + spell->caster.id = serializedReferenceId(spell->caster, ids); + spell->target.id = serializedReferenceId(spell->target, ids); + spell->area.id = serializedReferenceId(spell->area, ids); + spell->item.id = serializedReferenceId(spell->item, ids); } else if (auto scriptEvent = std::get_if(&event.payload)) { for (auto &reference : scriptEvent->objects) { - reference.id = serializedReferenceId(reference); + reference.id = serializedReferenceId(reference, ids); } } else if (auto bag = std::get_if(&event.payload)) { - bag->object.id = serializedReferenceId(bag->object); + bag->object.id = serializedReferenceId(bag->object, ids); + } else if (auto broadcast = std::get_if(&event.payload)) { + broadcast->target.id = serializedReferenceId(broadcast->target, ids); + } else if (auto combat = std::get_if(&event.payload)) { + combat->reactionObject.id = serializedReferenceId( + combat->reactionObject, ids); + combat->ammoItem.id = serializedReferenceId(combat->ammoItem, ids); + } else if (auto feedback = std::get_if(&event.payload)) { + for (auto &reference : feedback->objects) { + reference.id = serializedReferenceId(reference, ids); + } } } void ModuleSnapshotBuilder::appendRuntimeDelayedEvents( std::vector &events, - const Object &owner) const { + const Object &owner, + const ModuleObjectIdContext &ids) const { for (size_t index = 0; index < owner._delayed.size(); ++index) { const auto &delayed = owner._delayed[index]; if (!delayed.action || delayed.action->isCompleted() || @@ -904,20 +1026,28 @@ void ModuleSnapshotBuilder::appendRuntimeDelayedEvents( SavedEventRecord event; event.day = static_cast(absolute / millisecondsPerDay); event.time = static_cast(absolute % millisecondsPerDay); - event.object = SavedObjectReference(owner.id()); - event.caller = SavedObjectReference(owner.id()); + event.object = SavedObjectReference::fromRuntimeId(owner.id()); + event.caller = SavedObjectReference::fromRuntimeId(owner.id()); event.eventId = static_cast(SavedEventType::Timed); event.payload = *situation; - normalizeEventReferences(event); + normalizeEventReferences(event, ids); events.push_back(std::move(event)); } } std::shared_ptr ModuleSnapshotBuilder::writeItem( const Item &item, uint32_t structType, - std::optional serializedId) const { + std::optional serializedId, + const ModuleObjectIdContext *ids) const { auto result = objectBase(item, ResType::Uti, structType); - writeObjectState(*result, item); + if (ids) { + writeObjectState(*result, item, *ids); + } else { + ModuleObjectIdContext local( + SerializedIdentityContext::detachedRecord("standalone-item")); + local.allocateObject(item); + writeObjectState(*result, item, local); + } if (serializedId) { put(*result, Gff::Field::newDword("ObjectId", *serializedId)); } else { @@ -952,8 +1082,9 @@ std::shared_ptr ModuleSnapshotBuilder::writeItem( std::shared_ptr ModuleSnapshotBuilder::writeCreature( const Creature &creature, uint32_t structType, - std::optional serializedId) const { - ModuleObjectIdContext ids; + std::optional serializedId, + const SerializedIdentityContext &outputIdentityContext) const { + ModuleObjectIdContext ids(outputIdentityContext); std::set seen; std::vector items; auto addItem = [&](const std::shared_ptr &item) { @@ -964,8 +1095,10 @@ std::shared_ptr ModuleSnapshotBuilder::writeCreature( addItem(item); } for (const auto &item : creature._items) addItem(item); - for (const Item *item : items) ids.retainItem(*item); - for (const Item *item : items) ids.allocateItem(*item); + ids.retainObject(creature); + for (const Item *item : items) ids.retainObject(*item); + ids.allocateObject(creature); + for (const Item *item : items) ids.allocateObject(*item); return writeCreature(creature, structType, serializedId, ids, true); } std::shared_ptr ModuleSnapshotBuilder::writeCreature( @@ -974,7 +1107,7 @@ std::shared_ptr ModuleSnapshotBuilder::writeCreature( const ModuleObjectIdContext &ids, bool includeInventory) const { auto result = objectBase(creature, ResType::Utc, structType); - writeObjectState(*result, creature); + writeObjectState(*result, creature, ids); if (serializedId) { put(*result, Gff::Field::newDword("ObjectId", *serializedId)); } else { @@ -987,12 +1120,17 @@ std::shared_ptr ModuleSnapshotBuilder::writeCreature( put(*result, Gff::Field::newFloat("YOrientation", direction.y)); put(*result, Gff::Field::newFloat("ZOrientation", direction.z)); put(*result, Gff::Field::newByte("IsPC", creature._isPC)); + if (_game.isTSL()) { + put(*result, Gff::Field::newInt( + "AssignedPup", creature._assignedPuppet)); + } put(*result, Gff::Field::newWord("FactionID", static_cast(creature._faction))); put(*result, Gff::Field::newWord("Appearance_Type", creature._appearance)); put(*result, Gff::Field::newByte("Gender", static_cast(creature._gender))); put(*result, Gff::Field::newShort("HitPoints", creature._hitPoints)); put(*result, Gff::Field::newShort("MaxHitPoints", creature._maxHitPoints)); - put(*result, Gff::Field::newShort("CurrentHitPoints", creature._currentHitPoints)); + put(*result, Gff::Field::newShort( + "CurrentHitPoints", creature.serializedCurrentHitPoints())); put(*result, Gff::Field::newByte("Dead", creature._dead)); put(*result, Gff::Field::newShort("ForcePoints", creature._forcePoints)); put(*result, Gff::Field::newShort("CurrentForce", creature._currentForce)); @@ -1102,7 +1240,8 @@ std::shared_ptr ModuleSnapshotBuilder::writeCreature( for (const auto &[slot, item] : creature._equipment) { if (!item) continue; equipped.insert(item.get()); - auto saved = writeItem(*item, 1u << slot, ids.itemId(*item)); + auto saved = writeItem( + *item, 1u << slot, ids.objectId(*item), &ids); saved->setType(1u << slot); equipList.push_back(std::move(saved)); } @@ -1110,7 +1249,8 @@ std::shared_ptr ModuleSnapshotBuilder::writeCreature( if (includeInventory) { for (const auto &item : creature._items) { if (!item || equipped.count(item.get()) != 0) continue; - inventory.push_back(writeItem(*item, 0, ids.itemId(*item))); + inventory.push_back(writeItem( + *item, 0, ids.objectId(*item), &ids)); } } put(*result, Gff::Field::newList("Equip_ItemList", std::move(equipList))); @@ -1123,22 +1263,27 @@ std::shared_ptr ModuleSnapshotBuilder::writeCreature( perception->deepCopy()); } std::set perceived; - perceived.insert(creature._perception.seen.begin(), creature._perception.seen.end()); - perceived.insert(creature._perception.heard.begin(), creature._perception.heard.end()); + for (const auto &[id, reference] : creature._perception.seen) { + if (reference.resolve()) perceived.insert(id); + } + for (const auto &[id, reference] : creature._perception.heard) { + if (reference.resolve()) perceived.insert(id); + } std::vector> perceptions; for (uint32_t id : perceived) { auto objectIt = _game._objectById.find(id); if (objectIt == _game._objectById.end() || - !isSerializedWorldObject(*objectIt->second)) continue; - auto found = oldPerceptions.find(id); + !ids.contains(*objectIt->second)) continue; + uint32_t savedId = ids.objectId(*objectIt->second); + auto found = oldPerceptions.find(savedId); auto perception = found != oldPerceptions.end() ? found->second : emptyRecord(0); uint8_t data = static_cast( perception->getUint("PerceptionData") & ~0x3u); - if (creature._perception.seen.count(id) != 0) data |= 0x1; - if (creature._perception.heard.count(id) != 0) data |= 0x2; - put(*perception, Gff::Field::newDword("ObjectId", id)); + if (creature._perception.sees(id)) data |= 0x1; + if (creature._perception.hears(id)) data |= 0x2; + put(*perception, Gff::Field::newDword("ObjectId", savedId)); put(*perception, Gff::Field::newByte("PerceptionData", data)); perceptions.push_back(std::move(perception)); } @@ -1146,9 +1291,10 @@ std::shared_ptr ModuleSnapshotBuilder::writeCreature( return result; } -std::shared_ptr ModuleSnapshotBuilder::writeDoor(const Door &door) const { +std::shared_ptr ModuleSnapshotBuilder::writeDoor( + const Door &door, const ModuleObjectIdContext &ids) const { auto result = objectBase(door, ResType::Utd, 8); - writeObjectState(*result, door); + writeObjectState(*result, door, ids); putTransform(*result, door, true); put(*result, Gff::Field::newDword("Appearance", door._appearance)); put(*result, Gff::Field::newByte("GenericType", door._genericType)); @@ -1180,7 +1326,7 @@ std::shared_ptr ModuleSnapshotBuilder::writeDoor(const Door &door) const { std::shared_ptr ModuleSnapshotBuilder::writePlaceable( const Placeable &placeable, const ModuleObjectIdContext &ids) const { auto result = objectBase(placeable, ResType::Utp, 9); - writeObjectState(*result, placeable); + writeObjectState(*result, placeable, ids); putTransform(*result, placeable, true); put(*result, Gff::Field::newByte("Open", placeable._open)); put(*result, Gff::Field::newByte("Locked", placeable._locked)); @@ -1198,15 +1344,16 @@ std::shared_ptr ModuleSnapshotBuilder::writePlaceable( put(*result, Gff::Field::newByte("TrapDetectDC", placeable._trapDetectDC)); put(*result, Gff::Field::newByte("TrapOneShot", placeable._trapOneShot)); std::vector> items; - for (const auto &item : placeable._items) if (item) items.push_back(writeItem(*item, 0, ids.itemId(*item))); + for (const auto &item : placeable._items) if (item) items.push_back( + writeItem(*item, 0, ids.objectId(*item), &ids)); put(*result, Gff::Field::newList("ItemList", std::move(items))); return result; } std::shared_ptr ModuleSnapshotBuilder::writeTrigger( - const Trigger &trigger) const { + const Trigger &trigger, const ModuleObjectIdContext &ids) const { auto result = objectBase(trigger, ResType::Utt, 1); - writeObjectState(*result, trigger); + writeObjectState(*result, trigger, ids); putTransform(*result, trigger); put(*result, Gff::Field::newResRef("ScriptOnEnter", trigger._onEnter)); put(*result, Gff::Field::newResRef("ScriptOnExit", trigger._onExit)); @@ -1235,9 +1382,9 @@ std::shared_ptr ModuleSnapshotBuilder::writeTrigger( } std::shared_ptr ModuleSnapshotBuilder::writeEncounter( - const Encounter &encounter) const { + const Encounter &encounter, const ModuleObjectIdContext &ids) const { auto result = objectBase(encounter, ResType::Ute, 7); - writeObjectState(*result, encounter); + writeObjectState(*result, encounter, ids); putTransform(*result, encounter); put(*result, Gff::Field::newByte("Active", encounter._active)); put(*result, Gff::Field::newByte("Reset", encounter._reset)); @@ -1273,8 +1420,8 @@ std::shared_ptr ModuleSnapshotBuilder::writeEncounter( areaList.push_back(Gff::Builder().type(0) .field(Gff::Field::newDword( "AreaObject", - object && isSerializedWorldObject(*object) - ? object->id() + object && ids.contains(*object) + ? ids.objectId(*object) : kSavedRuntimeInvalidObjectId)).build()); } put(*result, Gff::Field::newList("AreaList", std::move(areaList))); @@ -1311,7 +1458,7 @@ std::shared_ptr ModuleSnapshotBuilder::writeEncounter( std::shared_ptr ModuleSnapshotBuilder::writeStore( const Store &store, const ModuleObjectIdContext &ids) const { auto result = objectBase(store, ResType::Utm, 11); - writeObjectState(*result, store); + writeObjectState(*result, store, ids); putTransform(*result, store); const auto direction = glm::vec3(-std::sin(store.getFacing()), std::cos(store.getFacing()), 0.0f); put(*result, Gff::Field::newFloat("XOrientation", direction.x)); @@ -1321,15 +1468,16 @@ std::shared_ptr ModuleSnapshotBuilder::writeStore( put(*result, Gff::Field::newResRef("OnOpenStore", store._onOpenStore)); put(*result, Gff::Field::newByte("BuySellFlag", store._buySellFlag)); std::vector> items; - for (const auto &item : store._items) if (item) items.push_back(writeItem(*item, 0, ids.itemId(*item))); + for (const auto &item : store._items) if (item) items.push_back( + writeItem(*item, 0, ids.objectId(*item), &ids)); put(*result, Gff::Field::newList("ItemList", std::move(items))); return result; } std::shared_ptr ModuleSnapshotBuilder::writeWaypoint( - const Waypoint &waypoint) const { + const Waypoint &waypoint, const ModuleObjectIdContext &ids) const { auto result = objectBase(waypoint, ResType::Utw, 5); - writeObjectState(*result, waypoint); + writeObjectState(*result, waypoint, ids); putTransform(*result, waypoint); auto direction = glm::vec3(-std::sin(waypoint.getFacing()), std::cos(waypoint.getFacing()), 0.0f); put(*result, Gff::Field::newFloat("XOrientation", direction.x)); @@ -1340,9 +1488,9 @@ std::shared_ptr ModuleSnapshotBuilder::writeWaypoint( } std::shared_ptr ModuleSnapshotBuilder::writeSound( - const Sound &sound) const { + const Sound &sound, const ModuleObjectIdContext &ids) const { auto result = objectBase(sound, ResType::Uts, 6); - writeObjectState(*result, sound); + writeObjectState(*result, sound, ids); putTransform(*result, sound); put(*result, Gff::Field::newByte("Active", sound._active)); put(*result, Gff::Field::newByte("Positional", sound._positional)); @@ -1423,6 +1571,10 @@ std::shared_ptr ModuleSnapshotBuilder::buildGit( result = emptyRecord(std::numeric_limits::max()); } + // A runtime snapshot is an authoritative instance graph even when its + // shadow originated in an installed template GIT. + put(*result, Gff::Field::newByte("UseTemplates", 0)); + std::map>> lists { {"Creature List", {}}, {"Door List", {}}, {"Placeable List", {}}, {"TriggerList", {}}, {"Encounter List", {}}, {"StoreList", {}}, @@ -1430,14 +1582,18 @@ std::shared_ptr ModuleSnapshotBuilder::buildGit( lists["CameraList"] = {}; for (const auto &object : area._objects) { if (!object || area._objectsToDestroy.count(object->id()) != 0) continue; - if (object->type() == ObjectType::Creature && _game._party.isMember(*object)) continue; + if (object->type() == ObjectType::Creature && + _game._party.isRetainedRuntimeRepresentation( + *static_cast(object.get()))) continue; switch (object->type()) { case ObjectType::Creature: lists["Creature List"].push_back(writeCreature( - *static_cast(object.get()), 4, object->id(), ids)); + *static_cast(object.get()), 4, + ids.objectId(*object), ids)); break; case ObjectType::Door: - lists["Door List"].push_back(writeDoor(*static_cast(object.get()))); + lists["Door List"].push_back( + writeDoor(*static_cast(object.get()), ids)); break; case ObjectType::Placeable: lists["Placeable List"].push_back(writePlaceable(*static_cast(object.get()), ids)); @@ -1448,27 +1604,32 @@ std::shared_ptr ModuleSnapshotBuilder::buildGit( // triggers reconstructs stale ordinary triggers on the next load. if (!static_cast(object.get())->isLinkedDoorTransition()) { lists["TriggerList"].push_back( - writeTrigger(*static_cast(object.get()))); + writeTrigger(*static_cast(object.get()), ids)); } break; case ObjectType::Encounter: - lists["Encounter List"].push_back(writeEncounter(*static_cast(object.get()))); + lists["Encounter List"].push_back( + writeEncounter(*static_cast(object.get()), ids)); break; case ObjectType::Store: lists["StoreList"].push_back(writeStore(*static_cast(object.get()), ids)); break; case ObjectType::Waypoint: - lists["WaypointList"].push_back(writeWaypoint(*static_cast(object.get()))); + lists["WaypointList"].push_back( + writeWaypoint(*static_cast(object.get()), ids)); break; case ObjectType::Sound: - lists["SoundList"].push_back(writeSound(*static_cast(object.get()))); + lists["SoundList"].push_back( + writeSound(*static_cast(object.get()), ids)); break; case ObjectType::Camera: lists["CameraList"].push_back(writeCamera( *static_cast(object.get()))); break; case ObjectType::Item: { - auto item = writeItem(*static_cast(object.get()), 0, object->id()); + auto item = writeItem( + *static_cast(object.get()), 0, + ids.objectId(*object), &ids); putTransform(*item, *object, true); lists["List"].push_back(std::move(item)); break; @@ -1506,23 +1667,30 @@ std::shared_ptr ModuleSnapshotBuilder::buildIfo( put(*result, Gff::Field::newResRef("Mod_ResRef", module._name)); put(*result, Gff::Field::newCExoString("Mod_Tag", module._tag.empty() ? module._name : module._tag)); put(*result, Gff::Field::newResRef("Mod_Entry_Area", area._name)); - put(*result, Gff::Field::newDword("Mod_Area", area._id)); + put(*result, Gff::Field::newDword("Mod_Area", ids.objectId(area))); + // A short-lived local A2 draft wrote this private extension. It is not a + // retail field and the contextual Module target needs no on-disk carrier. + removeSaveField(*result, "ReoneModObjId"); put(*result, Gff::Field::newFloat("Mod_Entry_X", module._info.entryPosition.x)); put(*result, Gff::Field::newFloat("Mod_Entry_Y", module._info.entryPosition.y)); put(*result, Gff::Field::newFloat("Mod_Entry_Z", module._info.entryPosition.z)); put(*result, Gff::Field::newFloat("Mod_Entry_Dir_X", -std::sin(module._info.entryFacing))); put(*result, Gff::Field::newFloat("Mod_Entry_Dir_Y", std::cos(module._info.entryFacing))); // Split the canonical clock into the retail pair. Records written here are - // therefore always normalized: Mod_TimeOfDay is below one day length. - put(*result, Gff::Field::newDword("Mod_CalendarDay", _game.worldTimeDay())); - put(*result, Gff::Field::newDword("Mod_TimeOfDay", _game.worldTimeOfDay())); + // therefore always normalized: Mod_PauseTime is below one day length. + // Remove fields emitted by the short-lived Reone naming mistake so a + // shadow merge cannot leave two competing clock representations. + removeSaveField(*result, "Mod_CalendarDay"); + removeSaveField(*result, "Mod_TimeOfDay"); + put(*result, Gff::Field::newDword("Mod_PauseDay", _game.worldTimeDay())); + put(*result, Gff::Field::newDword("Mod_PauseTime", _game.worldTimeOfDay())); put(*result, Gff::Field::newByte("Mod_MinPerHour", _game._minutesPerHour)); put(*result, Gff::Field::newDword64("Mod_Effect_NxtId", _game._effectIds.nextId())); put(*result, Gff::Field::newStruct("SWVarTable", writeLocals(module))); auto areaEntry = Gff::Builder().type(6) .field(Gff::Field::newResRef("Area_Name", area._name)) - .field(Gff::Field::newDword("ObjectId", area._id)).build(); + .field(Gff::Field::newDword("ObjectId", ids.objectId(area))).build(); put(*result, Gff::Field::newList("Mod_Area_list", {areaEntry})); uint32_t shadowNext = result->getUint("Mod_NextObjId0", 2); @@ -1543,7 +1711,7 @@ std::shared_ptr ModuleSnapshotBuilder::buildIfo( std::set delayedOwners; auto appendOwner = [&](const std::shared_ptr &owner) { if (owner && delayedOwners.insert(owner.get()).second) { - appendRuntimeDelayedEvents(eventSnapshot, *owner); + appendRuntimeDelayedEvents(eventSnapshot, *owner, ids); } }; appendOwner(_game._module); @@ -1562,14 +1730,15 @@ std::shared_ptr ModuleSnapshotBuilder::buildIfo( return std::tie(left.day, left.time) < std::tie(right.day, right.time); }); for (auto event : eventSnapshot) { - normalizeEventReferences(event); + normalizeEventReferences(event, ids); events.push_back(eventToGff(event, &_game)); } put(*result, Gff::Field::newList("EventQueue", std::move(events))); auto modulePlayer = _game._party.player(); if (!modulePlayer) throw ValidationException("module has no controlled player creature"); - auto player = writeCreature(*modulePlayer, 4, modulePlayer->id(), ids, false); + auto player = writeCreature( + *modulePlayer, 4, ids.objectId(*modulePlayer), ids, false); put(*player, Gff::Field::newByte( "Mod_IsPrimaryPlr", modulePlayer == _game._party.actualPlayer())); put(*result, Gff::Field::newList("Mod_PlayerList", {player})); @@ -1577,7 +1746,8 @@ std::shared_ptr ModuleSnapshotBuilder::buildIfo( std::vector> limbo; for (const auto &creature : module._limboCreatures) { if (creature) { - limbo.push_back(writeCreature(*creature, 4, creature->id(), ids)); + limbo.push_back(writeCreature( + *creature, 4, ids.objectId(*creature), ids)); } } put(*result, Gff::Field::newList("Creature List", std::move(limbo))); @@ -1665,8 +1835,10 @@ void ModuleSnapshotBuilder::validate(const SavedModuleSnapshot &snapshot) const auto ifo = roundTrip(snapshot.ifoBytes, "IFO "); auto are = roundTrip(snapshot.areBytes, "ARE "); auto git = roundTrip(snapshot.gitBytes, "GIT "); + const auto areaEntries = ifo->getList("Mod_Area_list"); if (ifo->getString("Mod_Entry_Area") != _game._module->_area->_name || - ifo->getUint("Mod_Area") != _game._module->_area->_id) { + areaEntries.size() != 1 || + ifo->getUint("Mod_Area") != areaEntries.front()->getUint("ObjectId")) { throw ValidationException("IFO active-area identity is inconsistent"); } uint32_t nextId = ifo->getUint("Mod_NextObjId0"); diff --git a/src/libs/game/object.cpp b/src/libs/game/object.cpp index 0b85b7be3..de1ac6392 100644 --- a/src/libs/game/object.cpp +++ b/src/libs/game/object.cpp @@ -21,6 +21,7 @@ #include #include "reone/game/di/services.h" +#include "reone/game/equipmentrules.h" #include "reone/game/game.h" #include "reone/game/object/item.h" #include "reone/game/script/savedsituation.h" @@ -42,7 +43,9 @@ static constexpr float kDefaultMaxObjectDistance = 2.0f; static constexpr float kMaxConversationDistance = 4.0f; static constexpr float kDistanceWalk = 4.0f; -void Object::deserialize(const resource::Gff &gff) { +void Object::deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { if (gff.readString(_tag, "Tag")) { boost::to_lower(_tag); } @@ -79,25 +82,126 @@ void Object::deserialize(const resource::Gff &gff) { } } - if (gff.has("ObjectId")) { - _items.clear(); + if (_type != ObjectType::Placeable && _type != ObjectType::Store && + _type != ObjectType::Item && dynamic_cast(this) == nullptr) { + deserializeOwnedItems( + gff, identityContext, SaveRecordOriginKind::ContainedItem); } - if (_type != ObjectType::Placeable && _type != ObjectType::Store) { - for (const auto &itemGff : gff.getList("ItemList")) { - std::shared_ptr item = _game.newOwnedItem(); - item->deserialize(*itemGff); - if (gff.has("ObjectId")) { - item->captureOwnerLocalSaveRecord( - *itemGff, - {SaveRecordOriginKind::ContainedItem, std::to_string(_id)}); - } - addItem(item); + deserializeRuntimeState(gff, identityContext); +} + +std::vector> Object::ownedRuntimeObjects() const { + return {_items.begin(), _items.end()}; +} + +std::shared_ptr Object::appendOwnedItemCandidate( + std::vector> &items, + const std::shared_ptr &item, + bool preserveSerializedIdentities) { + if (!item) { + throw ValidationException("Cannot stage a null owned Item"); + } + for (const auto &existing : items) { + if (!existing || existing.get() == item.get()) { + continue; + } + if (preserveSerializedIdentities && + (existing->serializedObjectIdentity() || + item->serializedObjectIdentity())) { + continue; + } + if (!existing->isStackCompatibleWith(*item)) { + continue; + } + if (existing->mergeStackFrom(*item)) { + item->setOwner(0); + _game.discardStagedRuntimeObjects({item}); + return existing; } } - deserializeRuntimeState(gff); + items.push_back(item); + return item; +} + +void Object::deserializeOwnedItems( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext, + SaveRecordOriginKind originKind, + bool forceDropable, + std::string originOwner) { + if (!identityContext.isSerializedState()) { + if (!gff.has("ItemList")) return; + std::vector> obsolete( + _items.begin(), _items.end()); + std::vector> replacement; + ItemAttributes replacementAttributes; + auto creature = dynamic_cast(this); + _game.replaceRuntimeObjectGraph( + obsolete, + [&]() { + for (const auto &itemGff : gff.getList("ItemList")) { + auto item = _game.newOwnedItem(*itemGff, identityContext); + if (forceDropable) item->setDropable(true); + item->setOwner(_id); + appendOwnedItemCandidate( + replacement, item, true); + } + if (creature) { + for (const auto &item : replacement) { + replacementAttributes.addItem(item, _services.game); + } + } + }, + [&]() noexcept { + _items = std::move(replacement); + if (creature) { + creature->itemAttributes() = + std::move(replacementAttributes); + } + }); + return; + } + + std::vector> obsolete(_items.begin(), _items.end()); + std::vector> replacement; + ItemAttributes replacementAttributes; + auto creature = dynamic_cast(this); + _game.replaceRuntimeObjectGraph( + obsolete, + [&]() { + for (const auto &itemGff : gff.getList("ItemList")) { + auto item = _game.newOwnedItem(*itemGff, identityContext); + item->captureSaveRecord( + *itemGff, + identityContext, + {originKind, + originOwner.empty() ? std::to_string(_id) : originOwner}); + if (forceDropable) { + item->setDropable(true); + } + item->setOwner(_id); + appendOwnedItemCandidate( + replacement, item, true); + } + if (creature) { + for (const auto &item : replacement) { + replacementAttributes.addItem(item, _services.game); + } + } + }, + [&]() noexcept { + _items = std::move(replacement); + if (creature) { + creature->itemAttributes() = + std::move(replacementAttributes); + } + }); } void Object::update(float dt) { + if (!isRuntimeLive()) { + return; + } updateActions(dt); updateEffects(dt); if (!_dead && canExecuteActions()) { @@ -125,7 +229,9 @@ void Object::setLocalBoolean(int index, bool value) { void Object::setLocalNumber(int index, int value) { _localNumbers[index] = value; } -void Object::deserializeRuntimeState(const resource::Gff &gff) { +void Object::deserializeRuntimeState( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { _localBooleans.clear(); _localNumbers.clear(); @@ -151,14 +257,19 @@ void Object::deserializeRuntimeState(const resource::Gff &gff) { _savedEffects.clear(); _savedActionQueue = SavedActionQueue {}; + _savedRuntimeIdentityContext = identityContext; + _savedEffectReferencesBound.clear(); + _savedActionReferencesBound.clear(); _savedRuntimeParsed = gff.has("EffectList") || gff.has("ActionList"); _savedRuntimePublished = false; _loadedSaveActionSlots.clear(); if (_savedRuntimeParsed) { for (const auto &effect : gff.getList("EffectList")) { - _savedEffects.push_back(EffectInstance::fromGff(*effect)); + _savedEffects.push_back(EffectInstance::fromGff( + *effect, identityContext)); } - _savedActionQueue = SavedActionQueue::fromGff(gff); + _savedActionQueue = SavedActionQueue::fromGff( + gff, identityContext); _effects.clear(); _actions.clear(); _delayed.clear(); @@ -201,12 +312,12 @@ void Object::bindSavedRuntimeState() { return; } for (auto &effect : _savedEffects) { - if (effect.creatorId != kSavedEffectInvalidObjectId) { - _game.bindEffectCreator(effect); - } + _savedEffectReferencesBound.push_back( + _game.bindEffectCreator(effect)); } for (auto &action : _savedActionQueue.actions) { - action.bindObjectReferences(_game); + _savedActionReferencesBound.push_back( + action.bindObjectReferences(_game)); } } @@ -217,7 +328,12 @@ void Object::publishSavedRuntimeState() { SavedScriptSituationImporter importer( _game, _services.resource.scripts); - for (const auto &savedEffect : _savedEffects) { + for (size_t index = 0; index < _savedEffects.size(); ++index) { + const auto &savedEffect = _savedEffects[index]; + if (index >= _savedEffectReferencesBound.size() || + !_savedEffectReferencesBound[index]) { + continue; + } EffectInstance effect(savedEffect); if (effect.durationType() == DurationType::Temporary) { auto remaining = _game.remainingEffectDuration(effect); @@ -234,7 +350,14 @@ void Object::publishSavedRuntimeState() { restoreEffect(std::move(effect)); } - for (const auto &savedAction : _savedActionQueue.actions) { + for (size_t index = 0; index < _savedActionQueue.actions.size(); ++index) { + const auto &savedAction = _savedActionQueue.actions[index]; + if (index >= _savedActionReferencesBound.size() || + !_savedActionReferencesBound[index]) { + _loadedSaveActionSlots.push_back( + LoadedSaveActionSlot {savedAction, {}, true}); + continue; + } auto action = savedAction.toRuntimeAction(_game, &importer); if (action) { action->attachSavedAction(savedAction); @@ -251,9 +374,27 @@ void Object::publishSavedRuntimeState() { } void Object::captureSaveRecord( - const resource::Gff &gff, SaveRecordOrigin origin) { + const resource::Gff &gff, + const SerializedIdentityContext &identityContext, + SaveRecordOrigin origin) { + std::optional identity; + uint32_t id = 0; + if (identityContext.isSerializedState() && + gff.readDword(id, "ObjectId")) { + identity = SerializedObjectIdentity {identityContext, id}; + } _saveRecordProvenance = SaveRecordProvenance { - SaveGffShadow::capture(gff), std::move(origin)}; + SaveGffShadow::capture(gff), std::move(origin), std::move(identity)}; +} + +void Object::assignSerializedObjectIdentity( + const SerializedObjectIdentity &identity) { + if (_saveRecordProvenance) { + _saveRecordProvenance->identity = identity; + } else { + _saveRecordProvenance = SaveRecordProvenance { + SaveGffShadow {}, SaveRecordOrigin {}, identity}; + } } std::vector Object::saveEffectSnapshot() const { @@ -344,6 +485,47 @@ std::vector Object::saveActionSnapshot() const { return result; } +void Object::retireAreaRuntimeState( + const std::set &retainedObjects) { + // The authoritative source snapshot was captured before this boundary. + // Discard rather than cancel: cancellation callbacks are live gameplay and + // must not mutate the already-frozen outgoing world. + for (auto &action : _actions) { + if (action) action->markCancelled(); + } + _actions.clear(); + _delayed.clear(); + _executingAction.reset(); + _loadedSaveActionSlots.clear(); + _savedActionQueue = SavedActionQueue {}; + + for (auto &effect : _effects) { + if (effect.effect) { + effect.effect->retireAreaRuntime(retainedObjects); + } + effect.retireAreaRuntimeBindings(retainedObjects); + } + _savedEffects.clear(); + _savedRuntimeParsed = false; + _savedRuntimePublished = false; + + // Rebase object-local bindings exactly as effects are rebased. Master/owner + // relations between retained session objects remain meaningful; every + // outgoing Area binding retires. A2 separately owns the saved-graph + // namespace, translation, and generation. + std::map retainedReferenceIds; + std::map> retainedReferences; + for (const auto &[field, binding] : _savedReferences) { + auto object = binding.resolve(); + if (!object || retainedObjects.count(object.get()) == 0) continue; + retainedReferenceIds.emplace(field, object->id()); + retainedReferences.emplace(field, object); + } + _savedReferenceIds = std::move(retainedReferenceIds); + _savedReferences = std::move(retainedReferences); + _lastHostileActor.reset(); +} + void Object::resolveSavedReferences( const std::function(uint32_t)> &resolver) { _savedReferences.clear(); @@ -356,7 +538,20 @@ void Object::resolveSavedReferences( std::shared_ptr Object::savedReference(std::string_view field) const { auto found = _savedReferences.find(std::string(field)); - return found == _savedReferences.end() ? nullptr : found->second.lock(); + return found == _savedReferences.end() ? nullptr : found->second.resolve(); +} + +uint32_t Object::getLastHostileActor() const { + auto actor = _lastHostileActor.resolve(); + return actor ? actor->id() : script::kObjectInvalid; +} + +void Object::setLastHostileActor(uint32_t actor) { + if (actor == script::kObjectInvalid) { + _lastHostileActor.reset(); + return; + } + _lastHostileActor = _game.getObjectById(actor); } void Object::clearAllActions(bool force) { @@ -396,14 +591,17 @@ void Object::clearAllActions(bool force) { } void Object::addAction(std::shared_ptr action) { + if (!isRuntimeLive()) return; _actions.push_back(std::move(action)); } void Object::addActionOnTop(std::shared_ptr action) { + if (!isRuntimeLive()) return; _actions.push_front(std::move(action)); } void Object::delayAction(std::shared_ptr action, float seconds) { + if (!isRuntimeLive()) return; DelayedAction delayed; delayed.action = std::move(action); delayed.timer = std::make_unique(seconds); @@ -450,6 +648,10 @@ void Object::executeActions(float dt) { return; } std::shared_ptr action(_actions.front()); + if (!action->runtimeDependenciesLive()) { + action->complete(); + return; + } _executingAction = action; try { action->execute(action, *this, dt); @@ -476,44 +678,81 @@ std::shared_ptr Object::getCurrentAction() const { std::shared_ptr Object::addItem(const std::string &resRef, int stackSize, bool dropable) { std::shared_ptr result; - - auto maybeItem = std::find_if(_items.begin(), _items.end(), [&resRef](auto &item) { - return item->tag() == resRef; - }); - if (maybeItem != _items.end()) { - result = *maybeItem; - int prevStackSize = result->stackSize(); - result->setStackSize(prevStackSize + stackSize); - - } else { - result = _game.newItem(); - result->loadFromBlueprint(resRef); - result->setStackSize(stackSize); - result->setDropable(dropable); - result->setOwner(_id); - - _items.push_back(result); - - if (Creature *creature = dyn_cast(this)) { - creature->itemAttributes().addItem(result, _services.game); - } - } + std::vector> noObsolete; + std::vector> replacement(_items); + ItemAttributes replacementAttributes; + auto creature = dyn_cast(this); + if (creature) replacementAttributes = creature->itemAttributes(); + _game.replaceRuntimeObjectGraph( + noObsolete, + [&]() { + auto candidate = _game.newItemFromBlueprint(resRef); + candidate->setStackSize(stackSize); + candidate->setDropable(dropable); + candidate->setOwner(_id); + result = appendOwnedItemCandidate( + replacement, candidate, false); + if (creature && result.get() == candidate.get()) { + replacementAttributes.addItem(candidate, _services.game); + } + }, + [&]() noexcept { + _items = std::move(replacement); + if (creature) { + creature->itemAttributes() = + std::move(replacementAttributes); + } + }); return result; } void Object::addItem(const std::shared_ptr &item) { - auto maybeItem = std::find_if(_items.begin(), _items.end(), [&item](auto &entry) { return entry->tag() == item->tag(); }); - if (maybeItem != _items.end()) { - // Re-adding an owned stack restores one consumed item; transferred stacks merge fully. - int stackSize = *maybeItem == item ? 1 : item->stackSize(); - (*maybeItem)->setStackSize((*maybeItem)->stackSize() + stackSize); - } else { - _items.push_back(item); - item->setOwner(_id); - if (Creature *creature = dyn_cast(this)) { - creature->itemAttributes().addItem(item, _services.game); + if (!item || (!item->isRuntimeLive() && !item->isPresentationOnly())) { + throw ValidationException("Cannot own a non-live runtime item"); + } + if (item->isEquipped()) { + throw ValidationException( + "Cannot add an Item while it still has an equipment owner edge"); + } + if (isActiveAreaOwnedItem(_game, item)) { + throw ValidationException( + "Cannot add an Item while it still has an Area ownership edge"); + } + if (item->owner() != 0 && item->owner() != script::kObjectInvalid && + item->owner() != _id) { + throw ValidationException("Runtime item already has another owner"); + } + auto alreadyOwned = std::find(_items.begin(), _items.end(), item); + if (alreadyOwned != _items.end()) { + // Re-adding an owned stack restores one consumed item. + if ((*alreadyOwned)->stackSize() < (*alreadyOwned)->maxStackSize()) { + (*alreadyOwned)->setStackSize((*alreadyOwned)->stackSize() + 1); } + return; + } + for (const auto &existing : _items) { + if (!existing->isStackCompatibleWith(*item)) { + continue; + } + if (existing->mergeStackFrom(*item)) { + item->setOwner(0); + _game.destroyRuntimeObjectGraph(item); + return; + } + } + std::vector> replacement(_items); + replacement.push_back(item); + auto creature = dyn_cast(this); + ItemAttributes replacementAttributes; + if (creature) { + replacementAttributes = creature->itemAttributes(); + replacementAttributes.addItem(item, _services.game); + } + _items = std::move(replacement); + item->setOwner(_id); + if (creature) { + creature->itemAttributes() = std::move(replacementAttributes); } } @@ -624,7 +863,10 @@ void Object::moveDropableItemsTo(Object &other) { // the inventory; the stack size is the credit amount. if (otherInParty && item->isCredits()) { _game.party().giveGold(item->stackSize()); + item->setOwner(0); + _game.destroyRuntimeObjectGraph(item); } else { + item->setOwner(0); other.addItem(item); } } else { @@ -634,6 +876,7 @@ void Object::moveDropableItemsTo(Object &other) { } void Object::applyEffect(const std::shared_ptr &effect, DurationType durationType, float duration) { + if (!isRuntimeLive()) return; if (auto saved = std::dynamic_pointer_cast(effect)) { EffectInstance instance(saved->instance()); if (instance.hasStableId()) { @@ -664,7 +907,7 @@ void Object::applyEffect(const std::shared_ptr &effect, DurationType dur return; } - EffectInstance instance; + EffectInstance instance = effect->saveFacingInstance(); instance.effect = effect; instance.id = _game.allocateEffectId(); instance.subType = static_cast(durationType); diff --git a/src/libs/game/object/area.cpp b/src/libs/game/object/area.cpp index 02322bee0..d7d2be2b8 100644 --- a/src/libs/game/object/area.cpp +++ b/src/libs/game/object/area.cpp @@ -53,6 +53,7 @@ #include "reone/scene/collision.h" #include "reone/scene/di/services.h" #include "reone/scene/graphs.h" +#include "reone/system/exception/validation.h" #include "reone/scene/node/grass.h" #include "reone/scene/node/grasscluster.h" #include "reone/scene/node/model.h" @@ -168,9 +169,13 @@ void Area::init() { _objectsByType.insert(std::make_pair(ObjectType::Sound, ObjectList())); } -void Area::load(std::string name, const Gff &are, const Gff &git, bool fromSave) { +void Area::load( + std::string name, + const Gff &are, + const Gff &git, + const SerializedIdentityContext &identityContext) { _name = std::move(name); - if (fromSave) { + if (identityContext.isSerializedState()) { _game.captureSaveResourceShadow( {SaveResourceKind::AreaAre, _name}, are); _game.captureSaveResourceShadow( @@ -179,11 +184,11 @@ void Area::load(std::string name, const Gff &are, const Gff &git, bool fromSave) auto areParsed = resource::generated::parseARE(are); auto gitParsed = resource::generated::parseGIT(git); - deserializeRuntimeState(are); + deserializeRuntimeState(are, identityContext); loadARE(areParsed); loadLYT(); - loadGIT(gitParsed, git, fromSave); + loadGIT(gitParsed, git, identityContext); loadVIS(); } @@ -299,18 +304,22 @@ void Area::applySceneProperties() { sceneGraph.setFog(fogProperties); } -void Area::loadGIT(const resource::generated::GIT &git, const resource::Gff &gff, bool fromSave) { +void Area::loadGIT( + const resource::generated::GIT &git, + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + _game.reserveSavedObjectIds(gff, identityContext, SerializedGraphRoot::AreaGit); loadProperties(git); - loadCreatures(gff, fromSave); - loadDoors(gff, fromSave); - loadPlaceables(gff, fromSave); - loadWaypoints(gff, fromSave); - loadTriggers(gff, fromSave); - loadSounds(gff, fromSave); - loadCameras(gff, fromSave); - loadEncounters(gff, fromSave); - loadStores(gff, fromSave); - loadItems(gff, fromSave); + loadCreatures(gff, identityContext); + loadDoors(gff, identityContext); + loadPlaceables(gff, identityContext); + loadWaypoints(gff, identityContext); + loadTriggers(gff, identityContext); + loadSounds(gff, identityContext); + loadCameras(gff, identityContext); + loadEncounters(gff, identityContext); + loadStores(gff, identityContext); + loadItems(gff, identityContext); } void Area::loadProperties(const resource::generated::GIT &git) { @@ -321,103 +330,112 @@ void Area::loadProperties(const resource::generated::GIT &git) { } } -void Area::loadCreatures(const resource::Gff &gff, bool fromSave) { +void Area::loadCreatures(const resource::Gff &gff, const SerializedIdentityContext &identityContext) { for (const auto &creatureGff : gff.getList("Creature List")) { - std::shared_ptr creature = fromSave ? _game.newCreature(*creatureGff, _sceneName) - : _game.newCreature(_sceneName); - creature->deserialize(*creatureGff); - if (fromSave) creature->captureSaveRecord(*creatureGff, {SaveRecordOriginKind::ActiveGitObject, _name}); + auto creature = _game.newCreature(*creatureGff, identityContext, _sceneName); + if (identityContext.isSerializedState()) { + creature->captureSaveRecord(*creatureGff, identityContext, {SaveRecordOriginKind::ActiveGitObject, _name}); + } landObject(*creature); add(creature); } } -void Area::loadDoors(const resource::Gff &gff, bool fromSave) { +void Area::loadDoors(const resource::Gff &gff, const SerializedIdentityContext &identityContext) { for (auto &doorGff : gff.getList("Door List")) { - std::shared_ptr door = fromSave ? _game.newDoor(*doorGff, _sceneName) - : _game.newDoor(_sceneName); - door->deserialize(*doorGff); - if (fromSave) door->captureSaveRecord(*doorGff, {SaveRecordOriginKind::ActiveGitObject, _name}); + auto door = _game.newDoor(*doorGff, identityContext, _sceneName); + if (identityContext.isSerializedState()) { + door->captureSaveRecord(*doorGff, identityContext, {SaveRecordOriginKind::ActiveGitObject, _name}); + } add(door); } } -void Area::loadPlaceables(const resource::Gff &gff, bool fromSave) { +void Area::loadPlaceables(const resource::Gff &gff, const SerializedIdentityContext &identityContext) { for (auto &placeableGff : gff.getList("Placeable List")) { - std::shared_ptr placeable = fromSave ? _game.newPlaceable(*placeableGff, _sceneName) - : _game.newPlaceable(_sceneName); - placeable->deserialize(*placeableGff); - if (fromSave) placeable->captureSaveRecord(*placeableGff, {SaveRecordOriginKind::ActiveGitObject, _name}); + auto placeable = _game.newPlaceable(*placeableGff, identityContext, _sceneName); + if (identityContext.isSerializedState()) { + placeable->captureSaveRecord(*placeableGff, identityContext, {SaveRecordOriginKind::ActiveGitObject, _name}); + } add(placeable); } } -void Area::loadWaypoints(const resource::Gff &gff, bool fromSave) { +void Area::loadWaypoints(const resource::Gff &gff, const SerializedIdentityContext &identityContext) { for (auto &waypointGff : gff.getList("WaypointList")) { - std::shared_ptr waypoint = fromSave ? _game.newWaypoint(*waypointGff, _sceneName) - : _game.newWaypoint(_sceneName); - waypoint->deserialize(*waypointGff); - if (fromSave) waypoint->captureSaveRecord(*waypointGff, {SaveRecordOriginKind::ActiveGitObject, _name}); + auto waypoint = _game.newWaypoint(*waypointGff, identityContext, _sceneName); + if (identityContext.isSerializedState()) { + waypoint->captureSaveRecord(*waypointGff, identityContext, {SaveRecordOriginKind::ActiveGitObject, _name}); + } add(waypoint); } } -void Area::loadTriggers(const resource::Gff &gff, bool fromSave) { +void Area::loadTriggers(const resource::Gff &gff, const SerializedIdentityContext &identityContext) { for (auto &triggerGff : gff.getList("TriggerList")) { - std::shared_ptr trigger = fromSave ? _game.newTrigger(*triggerGff, _sceneName) - : _game.newTrigger(_sceneName); - trigger->deserialize(*triggerGff); - if (fromSave) trigger->captureSaveRecord(*triggerGff, {SaveRecordOriginKind::ActiveGitObject, _name}); + auto trigger = _game.newTrigger(*triggerGff, identityContext, _sceneName); + if (identityContext.isSerializedState()) { + trigger->captureSaveRecord(*triggerGff, identityContext, {SaveRecordOriginKind::ActiveGitObject, _name}); + } add(trigger); } } -void Area::loadSounds(const resource::Gff &gff, bool fromSave) { +void Area::loadSounds(const resource::Gff &gff, const SerializedIdentityContext &identityContext) { for (auto &soundGff : gff.getList("SoundList")) { - std::shared_ptr sound = fromSave ? _game.newSound(*soundGff, _sceneName) - : _game.newSound(_sceneName); - sound->deserialize(*soundGff); - if (fromSave) sound->captureSaveRecord(*soundGff, {SaveRecordOriginKind::ActiveGitObject, _name}); + auto sound = _game.newSound(*soundGff, identityContext, _sceneName); + if (identityContext.isSerializedState()) { + sound->captureSaveRecord(*soundGff, identityContext, {SaveRecordOriginKind::ActiveGitObject, _name}); + } add(sound); } } -void Area::loadCameras(const resource::Gff &gff, bool fromSave) { +void Area::loadCameras(const resource::Gff &gff, const SerializedIdentityContext &identityContext) { for (auto &cameraGff : gff.getList("CameraList")) { - std::shared_ptr camera = _game.newStaticCamera(_sceneName); - camera->deserialize(*cameraGff); - if (fromSave) camera->captureSaveRecord(*cameraGff, {SaveRecordOriginKind::ActiveGitObject, _name}); + std::vector> noObsolete; + std::shared_ptr camera; + _game.replaceRuntimeObjectGraph( + noObsolete, + [&]() { + camera = _game.newStaticCamera(_sceneName); + camera->deserialize(*cameraGff); + }, + []() noexcept {}); + if (identityContext.isSerializedState()) { + camera->captureSaveRecord(*cameraGff, identityContext, {SaveRecordOriginKind::ActiveGitObject, _name}); + } add(camera); } } -void Area::loadEncounters(const resource::Gff &gff, bool fromSave) { +void Area::loadEncounters(const resource::Gff &gff, const SerializedIdentityContext &identityContext) { for (auto &encounterGff : gff.getList("Encounter List")) { - std::shared_ptr encounter = fromSave ? _game.newEncounter(*encounterGff, _sceneName) - : _game.newEncounter(_sceneName); - encounter->deserialize(*encounterGff); - if (fromSave) encounter->captureSaveRecord(*encounterGff, {SaveRecordOriginKind::ActiveGitObject, _name}); + auto encounter = _game.newEncounter(*encounterGff, identityContext, _sceneName); + if (identityContext.isSerializedState()) { + encounter->captureSaveRecord(*encounterGff, identityContext, {SaveRecordOriginKind::ActiveGitObject, _name}); + } add(encounter); } } -void Area::loadStores(const resource::Gff &gff, bool fromSave) { +void Area::loadStores(const resource::Gff &gff, const SerializedIdentityContext &identityContext) { for (auto &storeGff : gff.getList("StoreList")) { - std::shared_ptr store = fromSave ? _game.newStore(*storeGff, _sceneName) - : _game.newStore(_sceneName); - store->deserialize(*storeGff); - if (fromSave) store->captureSaveRecord(*storeGff, {SaveRecordOriginKind::ActiveGitObject, _name}); + auto store = _game.newStore(*storeGff, identityContext, _sceneName); + if (identityContext.isSerializedState()) { + store->captureSaveRecord(*storeGff, identityContext, {SaveRecordOriginKind::ActiveGitObject, _name}); + } add(store); } } -void Area::loadItems(const resource::Gff &gff, bool fromSave) { +void Area::loadItems(const resource::Gff &gff, const SerializedIdentityContext &identityContext) { for (auto &itemGff : gff.getList("List")) { - std::shared_ptr item = fromSave ? _game.newItem(*itemGff) - : _game.newItem(); - item->deserialize(*itemGff); - if (fromSave) item->captureSaveRecord(*itemGff, {SaveRecordOriginKind::ActiveGitObject, _name}); + auto item = _game.newItem(*itemGff, identityContext); + if (identityContext.isSerializedState()) { + item->captureSaveRecord(*itemGff, identityContext, {SaveRecordOriginKind::ActiveGitObject, _name}); + } add(item); } } @@ -521,42 +539,90 @@ void Area::initCameras(const glm::vec3 &entryPosition, float entryFacing) { glm::vec3 position(entryPosition); position.z += 1.7f; - auto &sceneGraph = _services.scene.graphs.get(_sceneName); - - _firstPersonCamera = _game.newFirstPersonCamera(glm::radians(kDefaultFieldOfView), _sceneName); - _firstPersonCamera->load(); - _firstPersonCamera->setPosition(position); - _firstPersonCamera->setFacing(entryFacing); + std::vector> noObsolete; + _game.replaceRuntimeObjectGraph( + noObsolete, + [&]() { + _firstPersonCamera = _game.newFirstPersonCamera( + glm::radians(kDefaultFieldOfView), _sceneName); + _firstPersonCamera->load(); + _firstPersonCamera->setPosition(position); + _firstPersonCamera->setFacing(entryFacing); - _thirdPersonCamera = _game.newThirdPersonCamera(_camStyleDefault, _sceneName); - _thirdPersonCamera->load(); - _thirdPersonCamera->setTargetPosition(position); - _thirdPersonCamera->setFacing(entryFacing); + _thirdPersonCamera = _game.newThirdPersonCamera( + _camStyleDefault, _sceneName); + _thirdPersonCamera->load(); + _thirdPersonCamera->setTargetPosition(position); + _thirdPersonCamera->setFacing(entryFacing); - _dialogCamera = _game.newDialogCamera(_camStyleDefault, _sceneName); - _dialogCamera->load(); + _dialogCamera = _game.newDialogCamera( + _camStyleDefault, _sceneName); + _dialogCamera->load(); - _animatedCamera = _game.newAnimatedCamera(_sceneName); - _animatedCamera->load(); + _animatedCamera = _game.newAnimatedCamera(_sceneName); + _animatedCamera->load(); + }, + []() noexcept {}); } void Area::add(const std::shared_ptr &object) { - _objects.push_back(object); - _objectsByType[object->type()].push_back(object); - _objectsByTag[object->tag()].push_back(object); + if (!object || !_game.isRuntimeObjectAttachable(*object)) { + throw ValidationException( + "Area can only own a published or staged runtime object"); + } + if (std::any_of( + _objects.begin(), _objects.end(), + [&object](const auto &existing) { + return existing.get() == object.get(); + })) { + throw ValidationException("Runtime object is already owned by this Area"); + } - determineObjectRoom(*object); - attachObjectToSceneGraph(object); + try { + _objects.push_back(object); + _objectsByType[object->type()].push_back(object); + _objectsByTag[object->tag()].push_back(object); - if (auto door = dyn_cast(object)) { - if ((door->linkedToFlags() == 1 || door->linkedToFlags() == 2) && - !door->linkedToModule().empty() && - !door->linkedTo().empty() && - !door->linkedTransitionGeometry().empty()) { - auto trigger = _game.newTrigger(_sceneName); - trigger->configureLinkedDoorTransition(door); - add(trigger); + determineObjectRoom(*object); + attachObjectToSceneGraph(object); + + if (auto door = dyn_cast(object)) { + if ((door->linkedToFlags() == 1 || door->linkedToFlags() == 2) && + !door->linkedToModule().empty() && + !door->linkedTo().empty() && + !door->linkedTransitionGeometry().empty()) { + std::vector> noObsolete; + std::shared_ptr trigger; + _game.replaceRuntimeObjectGraph( + noObsolete, + [&]() { + trigger = _game.newTrigger(_sceneName); + trigger->configureLinkedDoorTransition(door); + }, + []() noexcept {}); + try { + add(trigger); + } catch (...) { + if (trigger->isRuntimeLive()) { + _game.destroyRuntimeObjectGraph(trigger); + } + throw; + } + } } + } catch (...) { + auto owned = std::find_if( + _objects.begin(), _objects.end(), + [&object](const auto &existing) { + return existing.get() == object.get(); + }); + if (owned != _objects.end()) { + // Area ownership is published only by a successful return. Undo + // any partially installed indexes/tenancy/presentation while + // leaving semantic object lifetime with the caller. + detachObjectRuntime(object); + } + throw; } } @@ -627,34 +693,14 @@ void Area::doDestroyObjects() { _objectsToDestroy.clear(); } -void Area::doDestroyObject(uint32_t objectId) { - auto object = _game.getObjectById(objectId); - if (!object) { - return; - } - - if (auto door = dyn_cast(object)) { - std::vector linkedTriggerIds; - for (auto &triggerObject : _objectsByType[ObjectType::Trigger]) { - auto trigger = std::static_pointer_cast(triggerObject); - if (trigger->detachLinkedDoorTransition(*door)) { - linkedTriggerIds.push_back(trigger->id()); - } - } - for (auto triggerId : linkedTriggerIds) { - doDestroyObject(triggerId); - } - } - +void Area::detachObjectRuntime(const std::shared_ptr &object) { auto room = object->room(); if (room) { - room->removeTenant(object.get()); + object->setRoom(nullptr); } - // Drop the object from any trigger it was standing inside. A destroyed - // object never moves, so Trigger::update would otherwise keep it as a tenant - // indefinitely (leaking it and leaving the trigger stuck in the Inside - // state). Destruction is not an "exit", so no OnExit is fired. + // Drop the object from any trigger it was standing inside. Detachment is + // not an authored exit, so no OnExit is fired. for (auto &triggerObject : _objectsByType[ObjectType::Trigger]) { static_cast(*triggerObject).removeTenant(object.get()); } @@ -692,14 +738,22 @@ void Area::doDestroyObject(uint32_t objectId) { } } - auto maybeObject = std::find_if(_objects.begin(), _objects.end(), [&object](auto &o) { return o.get() == object.get(); }); + auto maybeObject = std::find_if( + _objects.begin(), _objects.end(), + [&object](auto &candidate) { + return candidate.get() == object.get(); + }); if (maybeObject != _objects.end()) { _objects.erase(maybeObject); } auto maybeTagObjects = _objectsByTag.find(object->tag()); if (maybeTagObjects != _objectsByTag.end()) { auto &tagObjects = maybeTagObjects->second; - auto maybeObjectByTag = std::find_if(tagObjects.begin(), tagObjects.end(), [&object](auto &o) { return o.get() == object.get(); }); + auto maybeObjectByTag = std::find_if( + tagObjects.begin(), tagObjects.end(), + [&object](auto &candidate) { + return candidate.get() == object.get(); + }); if (maybeObjectByTag != tagObjects.end()) { tagObjects.erase(maybeObjectByTag); } @@ -707,10 +761,79 @@ void Area::doDestroyObject(uint32_t objectId) { _objectsByTag.erase(maybeTagObjects); } } - auto &typeObjects = _objectsByType.find(object->type())->second; - auto maybeObjectByType = std::find_if(typeObjects.begin(), typeObjects.end(), [&object](auto &o) { return o.get() == object.get(); }); - if (maybeObjectByType != typeObjects.end()) { - typeObjects.erase(maybeObjectByType); + auto maybeTypeObjects = _objectsByType.find(object->type()); + if (maybeTypeObjects != _objectsByType.end()) { + auto &typeObjects = maybeTypeObjects->second; + auto maybeObjectByType = std::find_if( + typeObjects.begin(), typeObjects.end(), + [&object](auto &candidate) { + return candidate.get() == object.get(); + }); + if (maybeObjectByType != typeObjects.end()) { + typeObjects.erase(maybeObjectByType); + } + } + if (_hilightedObject.get() == object.get()) { + _hilightedObject.reset(); + } + if (_selectedObject.get() == object.get()) { + _selectedObject.reset(); + } +} + +bool Area::releaseObject(const std::shared_ptr &object) { + if (!object) { + return false; + } + auto owned = std::find_if( + _objects.begin(), _objects.end(), + [&object](const auto &candidate) { + return candidate.get() == object.get(); + }); + if (owned == _objects.end()) { + return false; + } + + detachObjectRuntime(object); + return true; +} + +bool Area::isObjectResident(const Object &object) const { + return std::any_of( + _objects.begin(), _objects.end(), + [&object](const auto &candidate) { + return candidate.get() == &object; + }); +} + +bool Area::isObjectPendingDestruction(const Object &object) const { + auto live = _game.getObjectById(object.id()); + return live.get() == &object && + _objectsToDestroy.find(object.id()) != _objectsToDestroy.end(); +} + +void Area::doDestroyObject(uint32_t objectId, bool destroyRuntimeObject) { + auto object = _game.getObjectById(objectId); + if (!object) { + return; + } + + if (auto door = dyn_cast(object)) { + std::vector linkedTriggerIds; + for (auto &triggerObject : _objectsByType[ObjectType::Trigger]) { + auto trigger = std::static_pointer_cast(triggerObject); + if (trigger->detachLinkedDoorTransition(*door)) { + linkedTriggerIds.push_back(trigger->id()); + } + } + for (auto triggerId : linkedTriggerIds) { + doDestroyObject(triggerId); + } + } + + detachObjectRuntime(object); + if (destroyRuntimeObject) { + _game.destroyRuntimeObjectGraph(object); } } @@ -867,16 +990,28 @@ void Area::loadPartyMember(const std::shared_ptr &member, int index, b } } -void Area::unloadPartyMember(const std::shared_ptr &member) { - // Party creatures persist across modules, but combat does not. Reset it - // before rebuilding their models in the destination area so powered - // weapons do not leak their active state through a transition. - member->deactivateCombat(0.0f); - doDestroyObject(member->id()); +void Area::retireCreatureAreaRuntime(const std::shared_ptr &creature) { + if (!creature || + std::find(_objects.begin(), _objects.end(), creature) == _objects.end()) { + return; + } + + auto runtimeObjects = _game.party().runtimeObjects(); + std::set retainedObjects; + for (const auto &object : runtimeObjects) { + if (object) retainedObjects.insert(object.get()); + } - // Party objects outlive this Area. Do not leave their semantic room - // membership pointing into the retiring module's room graph. - member->setRoom(nullptr); + // Creature-side handles must retire while Pathfinder and every referenced + // outgoing object are alive. Area-owned structural attachments follow, + // then the raw Room pointer is invalidated before Room destruction. + creature->retireAreaRuntime(_pathfinder, retainedObjects); + doDestroyObject(creature->id(), false); + creature->setRoom(nullptr); +} + +void Area::retirePartyMemberAreaRuntime(const std::shared_ptr &member) { + retireCreatureAreaRuntime(member); } void Area::loadParty(const glm::vec3 &position, float facing, bool preserveSavedPlacement) { @@ -892,17 +1027,103 @@ void Area::loadParty(const glm::vec3 &position, float facing, bool preserveSaved for (int i = 1; i < party.getSize(); ++i) { loadPartyMember(party.getMember(i), i, preserveSavedPlacement); } + int formationIndex = party.getSize(); + for (int puppet : party.persistedState().puppetIds) { + if (auto creature = party.getAvailablePuppet(puppet, true)) { + loadPartyMember(creature, formationIndex++, preserveSavedPlacement); + } + } } -void Area::unloadParty() { - for (auto &member : _game.party().members()) { - unloadPartyMember(member.creature); +void Area::retirePartyAreaRuntime() { + auto runtimeObjects = _game.party().runtimeObjects(); + for (const auto &object : runtimeObjects) { + if (object && object->type() == ObjectType::Creature) { + retireCreatureAreaRuntime(std::static_pointer_cast(object)); + } } } -void Area::reloadParty() { +void Area::repositionPartyMember( + const std::shared_ptr &member, + int index) { + + bool loaded = std::find(_objects.begin(), _objects.end(), member) != _objects.end(); + if (index > 0) { + auto leader = _game.party().getLeader(); + glm::vec3 position(leader->position()); + + if (index <= static_cast(kPartyFormationOffsets.size())) { + glm::quat rotation(glm::angleAxis( + leader->getFacing(), glm::vec3(0.0f, 0.0f, 1.0f))); + position += rotation * kPartyFormationOffsets[index - 1]; + } + + member->setPosition(findPartyPosition(*member, position)); + member->setFacing(leader->getFacing()); + } + + bool landed = landObject(*member); + if (index == 0 && !landed) { + glm::vec3 position(member->position()); + glm::vec3 fallbackPosition(position); + fallbackPosition.z = scene::kElevationTestZ; + + member->setPosition(fallbackPosition); + if (!landObject(*member)) { + member->setPosition(position); + } + } + + if (loaded) { + determineObjectRoom(*member); + return; + } + + add(member); + member->runSpawnScript(); +} + +void Area::placeControlledCreature( + const std::shared_ptr &creature, + const glm::vec3 &position, + float facing) { + + // Retail SwitchPlayerCharacter transfers control in the existing Area. + // Only the incoming actor inherits the outgoing leader's transform; + // unrelated followers and the parked actor retain their current runtime + // state and placement. + creature->setPosition(position); + creature->setFacing(facing); + repositionPartyMember(creature, 0); +} + +void Area::repositionParty(const glm::vec3 &position, float facing) { + // This is a same-Area operation. It deliberately does not call + // retireCreatureAreaRuntime: action/delay/effect and Area-lifetime state + // remain authoritative while control or party composition changes. + Party &party = _game.party(); + auto leader = party.getLeader(); + if (!leader) return; + + leader->setPosition(position); + leader->setFacing(facing); + repositionPartyMember(leader, 0); + for (int i = 1; i < party.getSize(); ++i) { + repositionPartyMember(party.getMember(i), i); + } + int formationIndex = party.getSize(); + for (int puppet : party.persistedState().puppetIds) { + if (auto creature = party.getAvailablePuppet(puppet, true)) { + repositionPartyMember(creature, formationIndex++); + } + } +} + +void Area::repositionParty() { auto leader = _game.party().getLeader(); - loadParty(leader->position(), leader->getFacing()); + if (!leader) return; + repositionParty(leader->position(), leader->getFacing()); } bool Area::handle(const input::Event &event) { @@ -1145,7 +1366,7 @@ glm::vec3 Area::getSelectableScreenCoords(const std::shared_ptr &object, void Area::update3rdPersonCameraFacing() { auto partyLeader = _game.party().getLeader(); - if (!partyLeader) { + if (!partyLeader || !_thirdPersonCamera) { return; } _thirdPersonCamera->setFacing(partyLeader->getFacing()); @@ -1353,22 +1574,22 @@ std::shared_ptr Area::createObject(ObjectType type, const std::string &b std::shared_ptr object; switch (type) { case ObjectType::Item: { - std::shared_ptr item = _game.newItem(); - item->loadFromBlueprint(blueprintResRef); + std::shared_ptr item = + _game.newItemFromBlueprint(blueprintResRef); object = std::move(item); break; } case ObjectType::Creature: { - std::shared_ptr creature = _game.newCreature(); - creature->loadFromBlueprint(blueprintResRef); + std::shared_ptr creature = + _game.newCreatureFromBlueprint(blueprintResRef); creature->setPosition(location->position()); creature->setFacing(location->facing()); object = std::move(creature); break; } case ObjectType::Placeable: { - std::shared_ptr placeable = _game.newPlaceable(); - placeable->loadFromBlueprint(blueprintResRef); + std::shared_ptr placeable = + _game.newPlaceableFromBlueprint(blueprintResRef); object = std::move(placeable); break; } @@ -1504,8 +1725,8 @@ static bool matchesPerception(const Creature &creature, const Object *target, } const Creature &targetCreature = static_cast(*target); - bool seen = targetCreature.perception().seen.count(creature.id()); - bool heard = targetCreature.perception().heard.count(creature.id()); + bool seen = targetCreature.perception().sees(creature.id()); + bool heard = targetCreature.perception().hears(creature.id()); switch (perception) { case PerceptionType::SeenAndHeard: @@ -1617,8 +1838,8 @@ void Area::doUpdatePerception() { } // Hearing - bool wasHeard = creature->perception().heard.count(other->id()) > 0; - bool wasSeen = creature->perception().seen.count(other->id()) > 0; + bool wasHeard = creature->perception().hears(other->id()); + bool wasSeen = creature->perception().sees(other->id()); if (wasHeard == heard && wasSeen == seen) { continue; // no change in perception @@ -1640,19 +1861,16 @@ void Area::doUpdatePerception() { } void Area::updateMessageBus() { - _messageBus.update([this](uint32_t speakerId, uint32_t listenerId, - int32_t number, TalkVolume volume) { - auto listener = _game.getObjectById(listenerId); - if (listener->type() != ObjectType::Creature) { - return; - } - Creature &creature = static_cast(*listener); - - bool heard = creature.perception().heard.count(speakerId); - if (!creature.isListening() || !heard) { + _messageBus.update([this]( + uint32_t speakerId, + const std::shared_ptr &listener, + int32_t number, + TalkVolume volume) { + bool heard = listener->perception().hears(speakerId); + if (!listener->isListening() || !heard) { return; } - creature.runDialogueScript(speakerId, number); + listener->runDialogueScript(speakerId, number); }); } diff --git a/src/libs/game/object/creature.cpp b/src/libs/game/object/creature.cpp index 621e45e3f..cccca3aea 100644 --- a/src/libs/game/object/creature.cpp +++ b/src/libs/game/object/creature.cpp @@ -28,6 +28,7 @@ #include "reone/game/d20/classes.h" #include "reone/game/debug.h" #include "reone/game/di/services.h" +#include "reone/game/equipmentrules.h" #include "reone/game/effect/acdecrease.h" #include "reone/game/effect/acincrease.h" #include "reone/game/effect/attackdecrease.h" @@ -40,6 +41,7 @@ #include "reone/game/effect/savingthrowincrease.h" #include "reone/game/footstepsounds.h" #include "reone/game/game.h" +#include "reone/game/party.h" #include "reone/game/portraits.h" #include "reone/game/script/runner.h" #include "reone/game/surfaces.h" @@ -65,6 +67,7 @@ #include "reone/script/types.h" #include "reone/system/clock.h" #include "reone/system/di/services.h" +#include "reone/system/exception/validation.h" #include "reone/system/logutil.h" #include "reone/system/randomutil.h" #include "reone/system/timer.h" @@ -530,6 +533,44 @@ Creature::Creature( _perception.hearingRange = 20.0f; } +void Creature::retireAreaRuntime( + Pathfinder &pathfinder, + const std::set &retainedObjects) { + retireAreaRuntimeState(retainedObjects); + + if (_path) { + releasePath(pathfinder, *_path); + _path.reset(); + } + _pathVelocity = glm::vec3(0.0f); + _previousPosition = _position; + _stuckTimer.reset(0.0f); + _stuckForce = glm::vec3(0.0f); + setMovementType(MovementType::None); + _blockingDoorId = script::kObjectInvalid; + _blockedEventDoorId = script::kObjectInvalid; + + deactivateCombat(0.0f); + _combatState.attackTarget.reset(); + _combatState.attemptedAttackTarget.reset(); + _combatState.attackAction = ActionType::QueueEmpty; + _combatState.combatFeat = FeatType::Invalid; + _combatState.deactivationTimer.reset(0.0f); + _lastHostileTarget.reset(); + _lastAttackAction = ActionType::QueueEmpty; + _lastCombatFeat = FeatType::Invalid; + _lastAttackResult = AttackResultType::Invalid; + + _perception.seen.clear(); + _perception.heard.clear(); + stopTalking(); + stopStuntMode(); + if (_audioSourceVoice) _audioSourceVoice->stop(); + if (_audioSourceFootstep) _audioSourceFootstep->stop(); + _audioSourceVoice.reset(); + _audioSourceFootstep.reset(); +} + void Creature::loadFromBlueprint(const std::string &resRef) { auto utc = _services.resource.gffs.get(resRef, ResType::Utc); if (!utc) { @@ -538,7 +579,8 @@ void Creature::loadFromBlueprint(const std::string &resRef) { // A blueprint is a single source, so deserialize it once. Routing through // deserialize() would re-read the self-referential TemplateResRef and // deserialize the same data twice, doubling accumulated class levels. - deserializeAll(*utc); + deserializeAll(*utc, SerializedIdentityContext::templateResource(resRef)); + restoreSerializedVitality(); updateTransform(); loadAppearance(); } @@ -715,9 +757,100 @@ void Creature::updateModelAnimation() { _animDirty = false; } -void Creature::restorePrimaryPlayerHitPoints() { - _currentHitPoints = maxHitPoints(); - _dead = false; +int Creature::derivePermanentMaxHitPoints() const { + int result = _attributes.getPermanentMaxHitPoints(_hitPoints); + return std::clamp( + result, + 0, + std::numeric_limits::max()); +} + +void Creature::updateDeathFromCurrentHitPoints() { + if (_minOneHP && _currentHitPoints < 1) { + _currentHitPoints = 1; + } + _dead = _currentHitPoints <= (_game.isTSL() && _isPC ? -10 : 0); +} + +void Creature::restoreSerializedVitality() { + // MaxHitPoints is a retail cache. HitPoints and CurrentHitPoints are both + // serialized on the base-vitality axis, so reconstruct the runtime value + // only after permanent attributes, levels and feats have been read. + if (_hitPoints <= 0 && _maxHitPoints > 0) { + // Preserve the long-standing tolerance for malformed/mod records that + // omit HitPoints but provide the cached maximum. + _hitPoints = _maxHitPoints; + } + + const int serializedCurrent = _currentHitPoints; + _maxHitPoints = derivePermanentMaxHitPoints(); + if (serializedCurrent > 0) { + const int damage = static_cast(_hitPoints) - serializedCurrent; + _currentHitPoints = static_cast(std::clamp( + static_cast(_maxHitPoints) - damage, + static_cast(std::numeric_limits::min()), + static_cast(std::numeric_limits::max()))); + } + updateDeathFromCurrentHitPoints(); +} + +int Creature::serializedCurrentHitPoints() const { + if (_currentHitPoints <= 0) { + return _currentHitPoints; + } + const int damage = static_cast(_maxHitPoints) - _currentHitPoints; + return std::clamp( + static_cast(_hitPoints) - damage, + static_cast(std::numeric_limits::min()), + static_cast(std::numeric_limits::max())); +} + +void Creature::recalculatePermanentVitality() { + const int oldMaximum = _maxHitPoints; + const int oldCurrent = _currentHitPoints; + _maxHitPoints = derivePermanentMaxHitPoints(); + if (oldCurrent > 0) { + _currentHitPoints = static_cast(std::clamp( + oldCurrent + static_cast(_maxHitPoints) - oldMaximum, + static_cast(std::numeric_limits::min()), + static_cast(std::numeric_limits::max()))); + } + updateDeathFromCurrentHitPoints(); +} + +void Creature::setMaxHitPoints(int baseHitPoints) { + const int oldMaximum = _maxHitPoints; + const int oldCurrent = _currentHitPoints; + _hitPoints = static_cast(std::clamp( + baseHitPoints, + 0, + static_cast(std::numeric_limits::max()))); + _maxHitPoints = derivePermanentMaxHitPoints(); + if (oldCurrent > 0) { + _currentHitPoints = static_cast(std::clamp( + oldCurrent + static_cast(_maxHitPoints) - oldMaximum, + static_cast(std::numeric_limits::min()), + static_cast(std::numeric_limits::max()))); + } + updateDeathFromCurrentHitPoints(); +} + +void Creature::setCurrentHitPoints(int hitPoints) { + _currentHitPoints = static_cast(std::clamp( + hitPoints, + static_cast(std::numeric_limits::min()), + static_cast(std::numeric_limits::max()))); + updateDeathFromCurrentHitPoints(); +} + +void Creature::initializeGeneratedVitality() { + _hitPoints = static_cast(std::clamp( + _attributes.getAggregateHitDie(), + 0, + static_cast(std::numeric_limits::max()))); + _maxHitPoints = derivePermanentMaxHitPoints(); + _currentHitPoints = _maxHitPoints; + updateDeathFromCurrentHitPoints(); } void Creature::damage(int amount, uint32_t damager) { @@ -775,10 +908,10 @@ void Creature::updateCombat(float dt) { _combatState.shouldDeactivate = false; _combatState.debilitated = false; _combatState.attackTarget.reset(); - _combatState.attemptedAttackTarget = script::kObjectInvalid; + _combatState.attemptedAttackTarget.reset(); _combatState.attackAction = ActionType::QueueEmpty; _combatState.combatFeat = FeatType::Invalid; - _lastHostileTarget = script::kObjectInvalid; + _lastHostileTarget.reset(); _lastAttackAction = ActionType::QueueEmpty; _lastCombatFeat = FeatType::Invalid; setLastHostileActor(script::kObjectInvalid); @@ -897,8 +1030,13 @@ void Creature::playAnimation(CombatAnimation anim, CreatureWieldType wield, int } bool Creature::equip(const std::string &resRef) { - std::shared_ptr item = _game.newItem(); - item->loadFromBlueprint(resRef); + std::shared_ptr item; + if (isPresentationOnly()) { + item = _game.newPresentationItem(); + item->loadFromBlueprint(resRef); + } else { + item = _game.newItemFromBlueprint(resRef); + } bool equipped = false; @@ -908,6 +1046,10 @@ bool Creature::equip(const std::string &resRef) { equipped = equip(InventorySlots::rightWeapon, item); } + if (!equipped && item->isRuntimeLive()) { + _game.destroyRuntimeObjectGraph(item); + } + return equipped; } @@ -931,20 +1073,129 @@ void Creature::updateDisguise() { } } +bool Creature::canEquip( + int slot, + const std::shared_ptr &item) const { + if (!item || (!item->isRuntimeLive() && !item->isPresentationOnly()) || + !item->isEquippable(getEquipabilitySlot(slot))) { + return false; + } + if (std::find(_items.begin(), _items.end(), item) != _items.end()) { + // Inventory -> equipment is a transfer. Callers must first remove or + // split the candidate so one object never has two ownership edges. + return false; + } + for (const auto &[equippedSlot, equipped] : _equipment) { + if (equipped == item && equippedSlot != slot) { + return false; + } + } + auto previous = getEquippedItem(slot); + if (previous != item && item->owner() != 0 && + item->owner() != script::kObjectInvalid) { + return false; + } + if (previous && previous != item) return false; + return true; +} + bool Creature::equip(int slot, const std::shared_ptr &item) { - if (!item->isEquippable(getEquipabilitySlot(slot))) { + if (!canEquip(slot, item) || isActiveAreaOwnedItem(_game, item)) { return false; } + _equipment[slot] = item; + item->setEquipped(true); + item->setOwner(_id); + + updateEquipmentPresentation(); + if (_sceneNode && _combatState.active && + (slot == InventorySlots::rightWeapon || + slot == InventorySlots::leftWeapon)) { + setLightsabersPowered(true, true); + } + + return true; +} +bool Creature::canReplaceEquipment( + int slot, + const std::shared_ptr &item, + const Object &displacedReceiver) const { auto previous = getEquippedItem(slot); - if (previous && previous != item) { - previous->powerDown(_position); - previous->setEquipped(false); + if (!previous || previous == item) return canEquip(slot, item); + if (!item || (!item->isRuntimeLive() && !item->isPresentationOnly()) || + !item->isEquippable(getEquipabilitySlot(slot)) || + std::find(_items.begin(), _items.end(), item) != _items.end() || + (item->owner() != 0 && item->owner() != script::kObjectInvalid)) { + return false; } + for (const auto &[equippedSlot, equipped] : _equipment) { + if (equipped == item && equippedSlot != slot) return false; + } + if ((!previous->isRuntimeLive() && !previous->isPresentationOnly()) || + (!displacedReceiver.isRuntimeLive() && + !displacedReceiver.isPresentationOnly()) || + (isPresentationOnly() != displacedReceiver.isPresentationOnly())) { + return false; + } + return true; +} + +bool Creature::replaceEquipment( + int slot, + const std::shared_ptr &item, + Object &displacedReceiver) { + auto previous = getEquippedItem(slot); + if (!previous || previous == item) return equip(slot, item); + if (!canReplaceEquipment(slot, item, displacedReceiver) || + isActiveAreaOwnedItem(_game, item)) { + return false; + } + + // All ordinary recoverable validation is complete. The remainder is one + // synchronous ownership move; only exceptional allocation failure remains. + previous->powerDown(_position); + previous->setEquipped(false); + previous->setOwner(0); _equipment[slot] = item; item->setEquipped(true); item->setOwner(_id); + displacedReceiver.addItem(previous); + updateEquipmentPresentation(); + return true; +} + +std::shared_ptr Creature::takeEquippedItem( + const std::shared_ptr &item) { + auto equipped = std::find_if( + _equipment.begin(), _equipment.end(), + [&item](const auto &entry) { return entry.second == item; }); + if (equipped == _equipment.end()) return nullptr; + + auto result = equipped->second; + result->powerDown(_position); + result->setEquipped(false); + result->setOwner(0); + _equipment.erase(equipped); + updateEquipmentPresentation(); + return result; +} + +bool Creature::moveEquippedItemTo( + const std::shared_ptr &item, + Object &receiver) { + if (!item || + (!receiver.isRuntimeLive() && !receiver.isPresentationOnly()) || + (isPresentationOnly() != receiver.isPresentationOnly())) { + return false; + } + auto taken = takeEquippedItem(item); + if (!taken) return false; + receiver.addItem(taken); + return true; +} +void Creature::updateEquipmentPresentation() { uint32_t prevAppearance = _appearance; updateDisguise(); if (_appearance != prevAppearance) { @@ -953,37 +1204,7 @@ bool Creature::equip(int slot, const std::shared_ptr &item) { // orphan it from the area scene graph. loadAppearanceProperties(); } - - if (_sceneNode) { - updateModel(); - - if (_combatState.active && - (slot == InventorySlots::rightWeapon || slot == InventorySlots::leftWeapon)) { - setLightsabersPowered(true, true); - } - } - - return true; -} - -void Creature::unequip(const std::shared_ptr &item) { - for (auto &equipped : _equipment) { - if (equipped.second != item) { - continue; - } - item->powerDown(_position); - item->setEquipped(false); - _equipment.erase(equipped.first); - uint32_t prevAppearance = _appearance; - updateDisguise(); - if (_appearance != prevAppearance) { - loadAppearanceProperties(); - } - if (_sceneNode) { - updateModel(); - } - break; - } + if (_sceneNode) updateModel(); } std::shared_ptr Creature::getEquippedItem(int slot) const { @@ -1199,7 +1420,7 @@ void Creature::stopTalking() { void Creature::setObjectSeen(const std::shared_ptr &object, bool seen) { if (seen) { - _perception.seen.insert(object->id()); + _perception.seen[object->id()] = object; } else { _perception.seen.erase(object->id()); } @@ -1207,7 +1428,7 @@ void Creature::setObjectSeen(const std::shared_ptr &object, bool seen) { void Creature::setObjectHeard(const std::shared_ptr &object, bool heard) { if (heard) { - _perception.heard.insert(object->id()); + _perception.heard[object->id()] = object; } else { _perception.heard.erase(object->id()); } @@ -1331,16 +1552,20 @@ std::shared_ptr Creature::getOffhandAttackWeapon() const { } void Creature::beginCombatAttack(std::shared_ptr target, FeatType feat) { - _combatState.attackTarget = std::move(target); + _combatState.attackTarget = target; _combatState.attackAction = ActionType::AttackObject; _combatState.combatFeat = feat; } +void Creature::setAttemptedAttackTarget(uint32_t target) { + _combatState.attemptedAttackTarget = _game.getObjectById(target); +} + void Creature::finishCombatRound() { - if (_combatState.attackTarget) { - _lastHostileTarget = _combatState.attackTarget->id(); + if (auto target = _combatState.attackTarget.resolve()) { + _lastHostileTarget = target; } else { - _lastHostileTarget = script::kObjectInvalid; + _lastHostileTarget.reset(); } _lastAttackAction = _combatState.attackAction; if (_combatState.combatFeat != FeatType::Invalid) { @@ -2960,34 +3185,36 @@ std::string Creature::getWeaponModelName(int slot) const { return modelName; } -void Creature::deserialize(const resource::Gff &gff) { +void Creature::deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { std::string templateRes; - if (!gff.has("ObjectId") && gff.readResRef(templateRes, "TemplateResRef")) { + if (!identityContext.isSerializedState() && + gff.readResRef(templateRes, "TemplateResRef")) { if (auto utc = _services.resource.gffs.get(templateRes, ResType::Utc)) { - deserializeAll(*utc); + deserializeAll( + *utc, + SerializedIdentityContext::templateResource(templateRes)); } } - deserializeAll(gff); + deserializeAll(gff, identityContext); + + restoreSerializedVitality(); updateTransform(); loadAppearance(); } -void Creature::deserializeAll(const resource::Gff &gff) { - Object::deserialize(gff); +void Creature::deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + Object::deserialize(gff, identityContext); // Retail reads IsPC before post-processing hit points. A player character // remains an incapacitated, resumable runtime object through 0..-9 HP and - // becomes truly dead only at -10 HP. Preserve the saved HP verbatim; this - // distinction is runtime state, not a load-time recovery/clamp. + // becomes truly dead only at -10 HP. gff.readBool(_isPC, "IsPC"); - if (gff.has("ObjectId") && gff.has("CurrentHitPoints")) { - if (_minOneHP && _currentHitPoints < 1) { - _currentHitPoints = 1; - } - _dead = _currentHitPoints <= (_game.isTSL() && _isPC ? -10 : 0); - } - + gff.readInt(_assignedPuppet, "AssignedPup"); // index into racialtypes.2da gff.readEnum(_race, "Race"); @@ -3070,7 +3297,7 @@ void Creature::deserializeAll(const resource::Gff &gff) { deserializeBodyBag(gff); deserializeAttributes(gff); deserializePerception(gff); - deserializeEquipItems(gff); + deserializeOwnedItemsAndEquipment(gff, identityContext); } void Creature::deserializeName(const resource::Gff &gff) { @@ -3190,40 +3417,191 @@ void Creature::deserializePerception(const resource::Gff &gff) { _perception.hearingRange = ranges->getFloat(_perceptionId, "secondaryrange"); } -void Creature::deserializeEquipItems(const resource::Gff &gff) { - if (gff.has("ObjectId")) { - _equipment.clear(); - } - for (const auto &itemGff : gff.getList("Equip_ItemList")) { - std::shared_ptr item = _game.newOwnedItem(); - item->deserialize(*itemGff); - if (gff.has("ObjectId")) { - item->captureOwnerLocalSaveRecord( - *itemGff, - {SaveRecordOriginKind::EquippedItem, std::to_string(_id)}); - } - if (gff.has("ObjectId")) { - uint32_t slotMask = itemGff->type(); - if (slotMask != 0 && (slotMask & (slotMask - 1)) == 0) { - int slot = 0; - while ((slotMask >>= 1) != 0) { - ++slot; +std::vector> Creature::ownedRuntimeObjects() const { + auto result = Object::ownedRuntimeObjects(); + std::set seen; + for (const auto &object : result) { + if (object) { + seen.insert(object.get()); + } + } + for (const auto &[_, item] : _equipment) { + if (item && seen.insert(item.get()).second) { + result.push_back(item); + } + } + return result; +} + +void Creature::deserializeOwnedItemsAndEquipment( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + const bool replaceItems = gff.has("ItemList"); + const bool replaceEquipment = gff.has("Equip_ItemList"); + if (!replaceItems && !replaceEquipment) return; + + if (!identityContext.isSerializedState()) { + std::vector> obsolete; + if (replaceItems) { + obsolete.insert(obsolete.end(), _items.begin(), _items.end()); + } + if (replaceEquipment) { + for (const auto &[_, item] : _equipment) { + obsolete.push_back(item); + } + } + std::vector> replacementItems = + replaceItems ? std::vector> {} : _items; + std::map> replacementEquipment = + replaceEquipment + ? std::map> {} + : _equipment; + ItemAttributes replacementAttributes; + _game.replaceRuntimeObjectGraph( + obsolete, + [&]() { + if (replaceItems) { + for (const auto &itemGff : gff.getList("ItemList")) { + auto item = _game.newOwnedItem(*itemGff, identityContext); + item->setOwner(_id); + appendOwnedItemCandidate( + replacementItems, item, true); + } } - if (equip(slot, item)) { - continue; + if (replaceEquipment) { + for (const auto &itemGff : gff.getList("Equip_ItemList")) { + auto item = _game.newOwnedItem(*itemGff, identityContext); + int slot = item->isEquippable(InventorySlots::body) + ? InventorySlots::body + : InventorySlots::rightWeapon; + if (!item->isEquippable(getEquipabilitySlot(slot)) || + replacementEquipment.count(slot) != 0) { + item->setOwner(_id); + appendOwnedItemCandidate( + replacementItems, item, true); + continue; + } + item->setOwner(_id); + item->setEquipped(true); + replacementEquipment.emplace(slot, std::move(item)); + } + } + for (const auto &item : replacementItems) { + replacementAttributes.addItem(item, _services.game); + } + }, + [&]() noexcept { + _items = std::move(replacementItems); + _equipment = std::move(replacementEquipment); + _itemAttributes = std::move(replacementAttributes); + }); + return; + } + + std::vector> obsolete; + if (replaceItems) { + obsolete.insert(obsolete.end(), _items.begin(), _items.end()); + } + if (replaceEquipment) { + for (const auto &[_, item] : _equipment) { + obsolete.push_back(item); + } + } + std::vector> replacementItems = + replaceItems ? std::vector> {} : _items; + std::map> replacementEquipment = + replaceEquipment + ? std::map> {} + : _equipment; + ItemAttributes replacementAttributes; + _game.replaceRuntimeObjectGraph( + obsolete, + [&]() { + if (replaceItems) { + for (const auto &itemGff : gff.getList("ItemList")) { + auto item = _game.newOwnedItem(*itemGff, identityContext); + item->captureSaveRecord( + *itemGff, + identityContext, + {SaveRecordOriginKind::ContainedItem, std::to_string(_id)}); + item->setOwner(_id); + appendOwnedItemCandidate( + replacementItems, item, true); } - warn(str(boost::format("saved item is not equippable in slot %d: %s") % slot % item->tag())); } - } - if (item->isEquippable(InventorySlots::body)) { - equip(InventorySlots::body, item); - } else if (item->isEquippable(InventorySlots::rightWeapon)) { - equip(InventorySlots::rightWeapon, item); - } else { - addItem(item); - warn(str(boost::format("item is not equippable: %s") % item->tag())); - } + if (replaceEquipment) { + for (const auto &itemGff : gff.getList("Equip_ItemList")) { + auto item = _game.newOwnedItem(*itemGff, identityContext); + item->captureSaveRecord( + *itemGff, + identityContext, + {SaveRecordOriginKind::EquippedItem, std::to_string(_id)}); + + std::optional slot; + uint32_t slotMask = itemGff->type(); + if (slotMask != 0 && (slotMask & (slotMask - 1)) == 0) { + int value = 0; + while ((slotMask >>= 1) != 0) { + ++value; + } + if (item->isEquippable(getEquipabilitySlot(value))) { + slot = value; + } + } + if (!slot && item->isEquippable(InventorySlots::body)) { + slot = InventorySlots::body; + } else if (!slot && + item->isEquippable(InventorySlots::rightWeapon)) { + slot = InventorySlots::rightWeapon; + } + + if (!slot) { + item->setOwner(_id); + appendOwnedItemCandidate( + replacementItems, item, true); + warn(str(boost::format("item is not equippable: %s") % + replacementItems.back()->tag())); + continue; + } + if (replacementEquipment.count(*slot) != 0) { + throw ValidationException( + "Multiple saved items occupy equipment slot " + + std::to_string(*slot)); + } + item->setOwner(_id); + item->setEquipped(true); + replacementEquipment.emplace(*slot, std::move(item)); + } + } + for (const auto &item : replacementItems) { + replacementAttributes.addItem(item, _services.game); + } + }, + [&]() noexcept { + if (replaceEquipment) { + for (auto &[_, item] : _equipment) { + if (item) { + item->setEquipped(false); + item->setOwner(0); + } + } + } + if (replaceItems) { + for (auto &item : _items) { + if (item) { + item->setOwner(0); + } + } + } + _items = std::move(replacementItems); + _equipment = std::move(replacementEquipment); + _itemAttributes = std::move(replacementAttributes); + }); + uint32_t previousAppearance = _appearance; + updateDisguise(); + if (_appearance != previousAppearance) { + loadAppearanceProperties(); } } diff --git a/src/libs/game/object/door.cpp b/src/libs/game/object/door.cpp index 6b1e2e358..998208252 100644 --- a/src/libs/game/object/door.cpp +++ b/src/libs/game/object/door.cpp @@ -49,21 +49,26 @@ namespace reone { namespace game { -void Door::deserialize(const resource::Gff &gff) { +void Door::deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { std::string templateRes; - if (!gff.has("ObjectId") && gff.readResRef(templateRes, "TemplateResRef")) { + if (!identityContext.isSerializedState() && + gff.readResRef(templateRes, "TemplateResRef")) { if (auto utd = _services.resource.gffs.get(templateRes, ResType::Utd)) { - deserializeAll(*utd); + deserializeAll(*utd, SerializedIdentityContext::templateResource(templateRes)); } } - deserializeAll(gff); + deserializeAll(gff, identityContext); loadAppearance(); applyRestingState(); updateTransform(); } -void Door::deserializeAll(const resource::Gff &gff) { - deserializeRuntimeState(gff); +void Door::deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + deserializeRuntimeState(gff, identityContext); if (gff.readString(_tag, "Tag")) { boost::to_lower(_tag); } @@ -114,7 +119,7 @@ void Door::deserializeAll(const resource::Gff &gff) { _maxHitPoints = _hitPoints; } gff.readShort(_currentHitPoints, "CurrentHP"); - if (gff.has("ObjectId") && gff.has("CurrentHP")) { + if (identityContext.isSerializedState() && gff.has("CurrentHP")) { _dead = _currentHitPoints <= 0; } gff.readBool(_plot, "Plot"); diff --git a/src/libs/game/object/encounter.cpp b/src/libs/game/object/encounter.cpp index fa0cee424..8142432c9 100644 --- a/src/libs/game/object/encounter.cpp +++ b/src/libs/game/object/encounter.cpp @@ -29,18 +29,23 @@ namespace reone { namespace game { -void Encounter::deserialize(const resource::Gff &gff) { +void Encounter::deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { std::string templateRes; - if (!gff.has("ObjectId") && gff.readResRef(templateRes, "TemplateResRef")) { + if (!identityContext.isSerializedState() && + gff.readResRef(templateRes, "TemplateResRef")) { if (auto ute = _services.resource.gffs.get(templateRes, ResType::Ute)) { - deserializeAll(*ute); + deserializeAll(*ute, SerializedIdentityContext::templateResource(templateRes)); } } - deserializeAll(gff); + deserializeAll(gff, identityContext); } -void Encounter::deserializeAll(const resource::Gff &gff) { - deserializeRuntimeState(gff); +void Encounter::deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + deserializeRuntimeState(gff, identityContext); deserializeSavedRuntimeState(gff); if (gff.readString(_tag, "Tag")) { boost::to_lower(_tag); diff --git a/src/libs/game/object/item.cpp b/src/libs/game/object/item.cpp index 9ef283b9b..918112ae1 100644 --- a/src/libs/game/object/item.cpp +++ b/src/libs/game/object/item.cpp @@ -33,6 +33,8 @@ #include "reone/resource/resources.h" #include "reone/resource/strings.h" +#include + using namespace reone::audio; using namespace reone::graphics; using namespace reone::resource; @@ -44,45 +46,43 @@ namespace game { void Item::loadFromBlueprint(const std::string &resRef) { std::shared_ptr uti(_services.resource.gffs.get(resRef, ResType::Uti)); if (uti) { - deserialize(*uti); + deserialize(*uti, SerializedIdentityContext::templateResource(resRef)); return; } } -void Item::deserialize(const resource::Gff &gff) { +void Item::deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { std::string ref; - if (!gff.has("ObjectId") && gff.readResRef(ref, "EquippedRes")) { + if (!identityContext.isSerializedState() && + gff.readResRef(ref, "EquippedRes")) { if (auto uti = _services.resource.gffs.get(ref, ResType::Uti)) { - deserializeAll(*uti); + deserializeAll(*uti, SerializedIdentityContext::templateResource(ref)); } } - if (!gff.has("ObjectId") && gff.readResRef(ref, "InventoryRes")) { + if (!identityContext.isSerializedState() && + gff.readResRef(ref, "InventoryRes")) { if (auto uti = _services.resource.gffs.get(ref, ResType::Uti)) { - deserializeAll(*uti); + deserializeAll(*uti, SerializedIdentityContext::templateResource(ref)); } } - if (!gff.has("ObjectId") && gff.readResRef(ref, "TemplateResRef")) { + if (!identityContext.isSerializedState() && + gff.readResRef(ref, "TemplateResRef")) { if (auto uti = _services.resource.gffs.get(ref, ResType::Uti)) { - deserializeAll(*uti); + deserializeAll(*uti, SerializedIdentityContext::templateResource(ref)); } } - deserializeAll(gff); -} - -void Item::captureOwnerLocalSaveRecord( - const resource::Gff &gff, SaveRecordOrigin origin) { - uint32_t savedId = 0; - _originalOwnerLocalObjectId = gff.readDword(savedId, "ObjectId") - ? std::optional(savedId) - : std::nullopt; - captureSaveRecord(gff, std::move(origin)); + deserializeAll(gff, identityContext); } -void Item::deserializeAll(const resource::Gff &gff) { - Object::deserialize(gff); +void Item::deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + Object::deserialize(gff, identityContext); gff.readLocString(_localizedName, "LocalizedName", _services.resource.strings); gff.readLocString(_description, "Description", _services.resource.strings); @@ -179,6 +179,13 @@ void Item::deserializeBase(const resource::Gff &gff) { baseItems->getInt(_baseItem, "focfeat", 0)); _weaponSpecializationFeat = static_cast( baseItems->getInt(_baseItem, "specfeat", 0)); + _maxStackSize = static_cast(std::clamp( + baseItems->getInt( + _baseItem, + "maxstack", + std::numeric_limits::max()), + 1, + static_cast(std::numeric_limits::max()))); _poweredItem = baseItems->getInt(_baseItem, "powereditem") != 0; if (_poweredItem) { @@ -227,7 +234,7 @@ void Item::loadAmmunitionType() { _ammunitionType->impactSound2 = _services.resource.audioClips.get(boost::to_lower_copy(twoDa->getString(ammunitionIdx, "impactsound1"))); } -void Item::clone(Item &from) { +void Item::clone(const Item &from) { // Object _tag = from._tag; _name = from._name; @@ -252,6 +259,7 @@ void Item::clone(Item &from) { _baseBodyVariation = from._baseBodyVariation; _itemClass = from._itemClass; + _maxStackSize = from._maxStackSize; _icon = from._icon; _equipableSlots = from._equipableSlots; @@ -364,6 +372,48 @@ bool Item::isEquippable(int slot) const { return (_equipableSlots >> slot) & 1; } +bool Item::isStackCompatibleWith(const Item &other) const { + auto sameProperty = [](const PropertyEntry &lhs, const PropertyEntry &rhs) { + return lhs.chanceAppear == rhs.chanceAppear && + lhs.costTable == rhs.costTable && + lhs.costValue == rhs.costValue && + lhs.paramTable == rhs.paramTable && + lhs.paramValue == rhs.paramValue && + lhs.propertyName == rhs.propertyName && + lhs.subtype == rhs.subtype && + lhs.upgradeType == rhs.upgradeType; + }; + return _maxStackSize > 1 && + _baseItem == other._baseItem && + _plot == other._plot && + _charges == other._charges && + _stolen == other._stolen && + _modelVariation == other._modelVariation && + _bodyVariation == other._bodyVariation && + _textureVariation == other._textureVariation && + _tag == other._tag && + _localizedName.id() == other._localizedName.id() && + _localizedName.str() == other._localizedName.str() && + _properties.size() == other._properties.size() && + std::equal( + _properties.begin(), + _properties.end(), + other._properties.begin(), + sameProperty); +} + +bool Item::mergeStackFrom(Item &other) { + if (&other == this || !isStackCompatibleWith(other) || + _stackSize >= _maxStackSize || other._stackSize == 0) { + return false; + } + const int moved = std::min( + _maxStackSize - _stackSize, other._stackSize); + _stackSize += moved; + other._stackSize -= moved; + return other._stackSize == 0; +} + void Item::setDropable(bool dropable) { _dropable = dropable; } diff --git a/src/libs/game/object/module.cpp b/src/libs/game/object/module.cpp index 72774197c..a1f00be05 100644 --- a/src/libs/game/object/module.cpp +++ b/src/libs/game/object/module.cpp @@ -67,6 +67,32 @@ static bool canBashPlaceable(const Placeable &placeable, const Creature &actor, } void Module::load(std::string name, const Gff &ifo, bool restoreSavedWorld) { + auto parsed = resource::generated::parseIFO(ifo); + if (parsed.Mod_Entry_Area.empty()) { + throw ValidationException("Mod_Entry_Area must not be empty"); + } + auto are = _services.resource.gffs.get(parsed.Mod_Entry_Area, ResType::Are); + if (!are) { + throw ResourceNotFoundException( + "Area ARE not found: " + parsed.Mod_Entry_Area); + } + auto git = _services.resource.gffs.get(parsed.Mod_Entry_Area, ResType::Git); + if (!git) { + throw ResourceNotFoundException( + "Area GIT not found: " + parsed.Mod_Entry_Area); + } + load(std::move(name), ifo, *are, *git, restoreSavedWorld); + if (!restoreSavedWorld) { + runSpawnScripts(); + } +} + +void Module::load( + std::string name, + const Gff &ifo, + const Gff &are, + const Gff &git, + bool restoreSavedWorld) { _name = std::move(name); if (restoreSavedWorld) { _game.captureSaveResourceShadow( @@ -78,23 +104,22 @@ void Module::load(std::string name, const Gff &ifo, bool restoreSavedWorld) { auto ifoParsed = resource::generated::parseIFO(ifo); _isSaveGame = restoreSavedWorld && ifoParsed.Mod_IsSaveGame != 0; if (restoreSavedWorld) { - deserializeRuntimeState(ifo); - deserializeSavedEventQueue(ifo); + const auto identityContext = + SerializedIdentityContext::moduleGraph(_name); + deserializeRuntimeState(ifo, identityContext); + deserializeSavedEventQueue(ifo, identityContext); loadLimboCreatures(ifo); } else { _savedEventQueue = SavedEventQueue {}; _savedEventLive.clear(); } loadInfo(ifoParsed); - loadArea(ifoParsed, restoreSavedWorld); + loadArea(ifoParsed, are, git, restoreSavedWorld); _area->initCameras(_info.entryPosition, _info.entryFacing); loadPlayer(); - if (!restoreSavedWorld) { - _area->runSpawnScripts(); - } } void Module::activate() { @@ -131,40 +156,55 @@ void Module::loadInfo(const resource::generated::IFO &ifo) { _info.onModStart = boost::to_lower_copy(ifo.Mod_OnModStart); } -void Module::loadArea(const resource::generated::IFO &ifo, bool restoreSavedWorld) { +void Module::loadArea( + const resource::generated::IFO &ifo, + const Gff &are, + const Gff &git, + bool restoreSavedWorld) { reone::info("Load area '" + _info.entryArea + "'"); - if (restoreSavedWorld) { - auto area = std::find_if( - ifo.Mod_Area_list.begin(), - ifo.Mod_Area_list.end(), - [this](const auto &entry) { return entry.Area_Name == _info.entryArea; }); - uint32_t areaId = area == ifo.Mod_Area_list.end() ? 1 : area->ObjectId; - _area = _game.newSavedArea(areaId); - } else { - _area = _game.newArea(); - } - - std::shared_ptr are(_services.resource.gffs.get(_info.entryArea, ResType::Are)); - if (!are) { - throw ResourceNotFoundException("Area ARE not found: " + _info.entryArea); - } - - std::shared_ptr git(_services.resource.gffs.get(_info.entryArea, ResType::Git)); - if (!git) { - throw ResourceNotFoundException("Area GIT not found: " + _info.entryArea); - } + const auto identityContext = restoreSavedWorld + ? SerializedIdentityContext::moduleGraph(_name) + : SerializedIdentityContext::templateResource(_name); + std::shared_ptr candidateArea; + std::vector> noObsolete; + _game.replaceRuntimeObjectGraph( + noObsolete, + [&]() { + if (restoreSavedWorld) { + auto area = std::find_if( + ifo.Mod_Area_list.begin(), + ifo.Mod_Area_list.end(), + [this](const auto &entry) { + return entry.Area_Name == _info.entryArea; + }); + uint32_t areaId = area == ifo.Mod_Area_list.end() + ? 1 + : area->ObjectId; + candidateArea = _game.newSavedArea(areaId, identityContext); + } else { + candidateArea = _game.newArea(); + } + candidateArea->load( + _info.entryArea, are, git, identityContext); + }, + [&]() noexcept { + _area = std::move(candidateArea); + }); - _area->load(_info.entryArea, *are, *git, restoreSavedWorld); +} +void Module::runSpawnScripts() { + _area->runSpawnScripts(); } void Module::loadLimboCreatures(const resource::Gff &ifo) { _limboCreatures.clear(); + const auto identityContext = SerializedIdentityContext::moduleGraph(_name); for (const auto &creatureGff : ifo.getList("Creature List")) { - auto creature = _game.newCreature(*creatureGff); - creature->deserialize(*creatureGff); + auto creature = _game.newCreature(*creatureGff, identityContext); creature->captureSaveRecord( *creatureGff, + identityContext, {SaveRecordOriginKind::ModuleLimboCreature, _name}); _limboCreatures.push_back(std::move(creature)); } @@ -353,7 +393,7 @@ void Module::onDoorClick(const std::shared_ptr &door) { if (!door->linkedToModule().empty() && door->getOnOpen().empty()) { std::shared_ptr partyLeader(_game.party().getLeader()); if (door->isLocked()) { - tryUnlockDoorWithKey(*door, *partyLeader, _game.party()); + tryUnlockDoorWithKey(_game, *door, *partyLeader, _game.party()); } if (door->isLocked()) { door->onFailToOpen(*partyLeader); @@ -390,9 +430,12 @@ size_t Module::pendingSavedEventCount() const { _savedEventLive.begin(), _savedEventLive.end(), true)); } -void Module::deserializeSavedEventQueue(const resource::Gff &ifo) { - _savedEventQueue = SavedEventQueue::fromGff(ifo); +void Module::deserializeSavedEventQueue( + const resource::Gff &ifo, + const SerializedIdentityContext &identityContext) { + _savedEventQueue = SavedEventQueue::fromGff(ifo, identityContext); _savedEventLive.clear(); + _savedEventReferencesBound.clear(); _savedEventLive.reserve(_savedEventQueue.events.size()); for (const auto &event : _savedEventQueue.events) { _savedEventLive.push_back(event.shouldRestore()); @@ -415,9 +458,22 @@ std::vector Module::saveEventSnapshot() const { size_t Module::enqueueSaveEvent(SavedEventRecord event) { // New events bind through the current B registry before becoming visible to // a save snapshot; raw IDs never gain cross-session authority. - event.bindObjectReferences(_game); + const bool referencesBound = event.bindObjectReferences(_game); _savedEventQueue.events.push_back(std::move(event)); _savedEventLive.push_back(true); + _savedEventReferencesBound.push_back(referencesBound); + return _savedEventQueue.events.size() - 1; +} + +size_t Module::enqueueBoundSaveEvent( + SavedEventRecord event, bool referencesBound) { + // Ordinary travel captures Party timers while the source registry still + // owns their reference domain. The record already carries C4 exact- + // incarnation handles; looking its numeric carriers up again after the + // destination publishes could alias an unrelated object. + _savedEventQueue.events.push_back(std::move(event)); + _savedEventLive.push_back(true); + _savedEventReferencesBound.push_back(referencesBound); return _savedEventQueue.events.size() - 1; } @@ -435,10 +491,11 @@ bool Module::cancelSaveEvent(size_t index) { } void Module::bindSavedEventQueue() { + _savedEventReferencesBound.clear(); + _savedEventReferencesBound.reserve(_savedEventQueue.events.size()); for (auto &event : _savedEventQueue.events) { - if (event.shouldRestore()) { - event.bindObjectReferences(_game); - } + _savedEventReferencesBound.push_back( + !event.shouldRestore() || event.bindObjectReferences(_game)); } } @@ -453,6 +510,8 @@ void Module::publishSavedEventQueue() { for (size_t index = 0; index < _savedEventQueue.events.size(); ++index) { const auto &savedEvent = _savedEventQueue.events[index]; if (!savedEvent.shouldRestore() || + index >= _savedEventReferencesBound.size() || + !_savedEventReferencesBound[index] || savedEvent.executionSupport() != SavedExecutionSupport::Executable) { continue; } diff --git a/src/libs/game/object/placeable.cpp b/src/libs/game/object/placeable.cpp index 6844b41aa..ad42a67c7 100644 --- a/src/libs/game/object/placeable.cpp +++ b/src/libs/game/object/placeable.cpp @@ -51,23 +51,28 @@ void Placeable::loadFromBlueprint(const std::string &resRef) { if (!utp) { return; } - deserialize(*utp); + deserialize(*utp, SerializedIdentityContext::templateResource(resRef)); } -void Placeable::deserialize(const resource::Gff &gff) { +void Placeable::deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { std::string templateRes; - if (!gff.has("ObjectId") && gff.readResRef(templateRes, "TemplateResRef")) { + if (!identityContext.isSerializedState() && + gff.readResRef(templateRes, "TemplateResRef")) { if (auto utp = _services.resource.gffs.get(templateRes, ResType::Utp)) { - deserializeAll(*utp); + deserializeAll(*utp, SerializedIdentityContext::templateResource(templateRes)); } } - deserializeAll(gff); + deserializeAll(gff, identityContext); loadAppearance(); updateTransform(); } -void Placeable::deserializeAll(const resource::Gff &gff) { - deserializeRuntimeState(gff); +void Placeable::deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + deserializeRuntimeState(gff, identityContext); if (gff.readString(_tag, "Tag")) { boost::to_lower(_tag); } @@ -97,7 +102,7 @@ void Placeable::deserializeAll(const resource::Gff &gff) { } gff.readShort(_currentHitPoints, "CurrentHP"); gff.readByte(_hardness, "Hardness"); - if (gff.has("ObjectId") && gff.has("CurrentHP")) { + if (identityContext.isSerializedState() && gff.has("CurrentHP")) { _dead = _currentHitPoints <= 0; } gff.readByte(_fort, "Fort"); @@ -147,20 +152,11 @@ void Placeable::deserializeAll(const resource::Gff &gff) { gff.readBool(_isCorpse, "IsCorpse"); gff.readBool(_commandable, "Commandable"); - if (gff.has("ObjectId")) { - _items.clear(); - } - for (const auto &itemGff : gff.getList("ItemList")) { - std::shared_ptr item = _game.newOwnedItem(); - item->deserialize(*itemGff); - if (gff.has("ObjectId")) { - item->captureOwnerLocalSaveRecord( - *itemGff, - {SaveRecordOriginKind::PlaceableItem, std::to_string(_id)}); - } - item->setDropable(true); - addItem(item); - } + deserializeOwnedItems( + gff, + identityContext, + SaveRecordOriginKind::PlaceableItem, + true); // FIXME: deserialize EffectList, ActionList } diff --git a/src/libs/game/object/sound.cpp b/src/libs/game/object/sound.cpp index efd16ca40..6fd7e528d 100644 --- a/src/libs/game/object/sound.cpp +++ b/src/libs/game/object/sound.cpp @@ -39,23 +39,28 @@ void Sound::loadFromBlueprint(const std::string &resRef) { if (!uts) { return; } - deserialize(*uts); + deserialize(*uts, SerializedIdentityContext::templateResource(resRef)); } -void Sound::deserialize(const resource::Gff &gff) { +void Sound::deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { std::string templateRes; - if (!gff.has("ObjectId") && gff.readResRef(templateRes, "TemplateResRef")) { + if (!identityContext.isSerializedState() && + gff.readResRef(templateRes, "TemplateResRef")) { if (auto uts = _services.resource.gffs.get(templateRes, ResType::Uts)) { - deserializeAll(*uts); + deserializeAll(*uts, SerializedIdentityContext::templateResource(templateRes)); } } - deserializeAll(gff); + deserializeAll(gff, identityContext); loadAppearance(); updateTransform(); } -void Sound::deserializeAll(const resource::Gff &gff) { - deserializeRuntimeState(gff); +void Sound::deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + deserializeRuntimeState(gff, identityContext); if (gff.readString(_tag, "Tag")) { boost::to_lower(_tag); } diff --git a/src/libs/game/object/store.cpp b/src/libs/game/object/store.cpp index 0cba1684f..25d90d0af 100644 --- a/src/libs/game/object/store.cpp +++ b/src/libs/game/object/store.cpp @@ -30,19 +30,24 @@ namespace reone { namespace game { -void Store::deserialize(const resource::Gff &gff) { +void Store::deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { std::string templateRes; - if (!gff.has("ObjectId") && gff.readResRef(templateRes, "ResRef")) { + if (!identityContext.isSerializedState() && + gff.readResRef(templateRes, "ResRef")) { if (auto utm = _services.resource.gffs.get(templateRes, ResType::Utm)) { - deserializeAll(*utm); + deserializeAll(*utm, SerializedIdentityContext::templateResource(templateRes)); } } - deserializeAll(gff); + deserializeAll(gff, identityContext); updateTransform(); } -void Store::deserializeAll(const resource::Gff &gff) { - deserializeRuntimeState(gff); +void Store::deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + deserializeRuntimeState(gff, identityContext); if (gff.readString(_tag, "Tag")) { boost::to_lower(_tag); } @@ -68,20 +73,14 @@ void Store::deserializeAll(const resource::Gff &gff) { gff.readBool(_commandable, "Commandable"); - deserializeItems(gff); + deserializeItems(gff, identityContext); } -void Store::deserializeItems(const resource::Gff &gff) { - for (const auto &itemGff : gff.getList("ItemList")) { - std::shared_ptr item = _game.newOwnedItem(); - item->deserialize(*itemGff); - if (gff.has("ObjectId")) { - item->captureOwnerLocalSaveRecord( - *itemGff, - {SaveRecordOriginKind::StoreItem, std::to_string(_id)}); - } - addItem(item); - } +void Store::deserializeItems( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + deserializeOwnedItems( + gff, identityContext, SaveRecordOriginKind::StoreItem); } } // namespace game diff --git a/src/libs/game/object/trigger.cpp b/src/libs/game/object/trigger.cpp index 5361bdc47..c173c91ee 100644 --- a/src/libs/game/object/trigger.cpp +++ b/src/libs/game/object/trigger.cpp @@ -60,17 +60,20 @@ void Trigger::loadFromBlueprint(const std::string &resRef) { if (!utt) { return; } - deserialize(*utt); + deserialize(*utt, SerializedIdentityContext::templateResource(resRef)); } -void Trigger::deserialize(const resource::Gff &gff) { +void Trigger::deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { std::string templateRes; - if (!gff.has("ObjectId") && gff.readResRef(templateRes, "TemplateResRef")) { + if (!identityContext.isSerializedState() && + gff.readResRef(templateRes, "TemplateResRef")) { if (auto utt = _services.resource.gffs.get(templateRes, ResType::Utt)) { - deserializeAll(*utt); + deserializeAll(*utt, SerializedIdentityContext::templateResource(templateRes)); } } - deserializeAll(gff); + deserializeAll(gff, identityContext); loadAppearance(); updateTransform(); } @@ -91,8 +94,10 @@ void Trigger::configureLinkedDoorTransition(const std::shared_ptr &door) { updateTransform(); } -void Trigger::deserializeAll(const resource::Gff &gff) { - deserializeRuntimeState(gff); +void Trigger::deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + deserializeRuntimeState(gff, identityContext); gff.readResRef(_onHeartbeat, "ScriptHeartbeat"); gff.readResRef(_onEnter, "ScriptOnEnter"); gff.readResRef(_onExit, "ScriptOnExit"); @@ -246,7 +251,7 @@ bool Trigger::isActive() const { if (!_linkedDoorTransition) { return true; } - auto door = _linkedDoor.lock(); + auto door = _linkedDoor.resolve(); return door && door->isOpen(); } @@ -266,7 +271,7 @@ bool Trigger::acceptsTransitionActivator(const std::shared_ptr &activato } bool Trigger::detachLinkedDoorTransition(const Door &door) { - auto linkedDoor = _linkedDoor.lock(); + auto linkedDoor = _linkedDoor.resolve(); if (!_linkedDoorTransition || linkedDoor.get() != &door) { return false; } diff --git a/src/libs/game/object/waypoint.cpp b/src/libs/game/object/waypoint.cpp index 126f31403..d8c7e3b02 100644 --- a/src/libs/game/object/waypoint.cpp +++ b/src/libs/game/object/waypoint.cpp @@ -37,22 +37,27 @@ void Waypoint::loadFromBlueprint(const std::string &resRef) { if (!utw) { return; } - deserialize(*utw); + deserialize(*utw, SerializedIdentityContext::templateResource(resRef)); } -void Waypoint::deserialize(const resource::Gff &gff) { +void Waypoint::deserialize( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { std::string templateRes; - if (!gff.has("ObjectId") && gff.readResRef(templateRes, "TemplateResRef")) { + if (!identityContext.isSerializedState() && + gff.readResRef(templateRes, "TemplateResRef")) { if (auto utw = _services.resource.gffs.get(templateRes, ResType::Utw)) { - deserializeAll(*utw); + deserializeAll(*utw, SerializedIdentityContext::templateResource(templateRes)); } } - deserializeAll(gff); + deserializeAll(gff, identityContext); updateTransform(); } -void Waypoint::deserializeAll(const resource::Gff &gff) { - deserializeRuntimeState(gff); +void Waypoint::deserializeAll( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + deserializeRuntimeState(gff, identityContext); if (gff.readString(_tag, "Tag")) { boost::to_lower(_tag); } diff --git a/src/libs/game/party.cpp b/src/libs/game/party.cpp index 3e36f6ce4..63f7ed8eb 100644 --- a/src/libs/game/party.cpp +++ b/src/libs/game/party.cpp @@ -21,6 +21,7 @@ #include "reone/game/game.h" #include "reone/game/object/creature.h" +#include "reone/game/types.h" #include "reone/system/logutil.h" #include "reone/system/randomutil.h" @@ -38,6 +39,19 @@ void Party::setPersistedState(PersistedState state) { _persistedState = std::move(state); } +void Party::loadPersistedState(PersistedState state) { + // Retail LoadTableInfo clears every transient object binding before it + // publishes the persisted PartyTable fields. A new session must rebuild + // active and inactive runtime representations explicitly. + for (const auto &[_, puppet] : _puppetBindings) { + if (puppet) puppet->setPuppet(false); + } + _members.clear(); + _npcBindings.clear(); + _puppetBindings.clear(); + setPersistedState(std::move(state)); +} + void Party::setPazaakData( PazaakCardCounts counts, PazaakSideDeck sideDeck, @@ -53,12 +67,17 @@ void Party::setPazaakSideDeck(PazaakSideDeck sideDeck) { _pazaakSideDeck = std::move(sideDeck); } -void Party::setDefaultPazaakData(size_t cardCount) { +void Party::initializeNewGameState() { + // Both titles start with two copies each of +1 through +5 and an empty + // side deck, but their PARTYTABLE ownership arrays have different sizes. PazaakCardCounts counts {}; counts[0] = counts[1] = counts[2] = counts[3] = counts[4] = 2; PazaakSideDeck sideDeck; sideDeck.fill(-1); - setPazaakData(std::move(counts), std::move(sideDeck), cardCount); + setPazaakData( + std::move(counts), + std::move(sideDeck), + _game.isTSL() ? kK2PazaakCardCount : kK1PazaakCardCount); } bool Party::handle(const input::Event &event) { @@ -83,64 +102,337 @@ bool Party::handleKeyDown(const input::KeyEvent &event) { } bool Party::addAvailableMember(int npc, const std::string &blueprint) { - if (isMemberAvailable(npc)) { - warn(str(boost::format("Party: NPC %d already exists") % npc)); - return false; + return addAvailableRosterRecord( + {RosterKind::Npc, npc}, blueprint); +} + +bool Party::addAvailableMember(int npc, std::shared_ptr creature) { + return makeRosterAvailableAndBind( + {RosterKind::Npc, npc}, creature); +} + +bool Party::removeAvailableMember(int npc) { + return setRosterAvailable({RosterKind::Npc, npc}, false); +} + +bool Party::addAvailablePuppet(int puppet, std::shared_ptr creature) { + return makeRosterAvailableAndBind( + {RosterKind::Puppet, puppet}, creature); +} + +std::shared_ptr Party::getAvailablePuppet(int puppet) const { + if (!isRosterAvailable({RosterKind::Puppet, puppet})) { + return nullptr; + } + auto found = _puppetBindings.find(puppet); + return found == _puppetBindings.end() ? nullptr : found->second; +} + +std::shared_ptr Party::getAvailablePuppet( + int puppet, bool loadIfMissing) { + return rosterCreature({RosterKind::Puppet, puppet}, loadIfMissing); +} + +std::shared_ptr Party::rosterCreature( + const RosterIdentity &identity) const { + const auto &bindings = identity.kind == RosterKind::Npc + ? _npcBindings + : _puppetBindings; + auto found = bindings.find(identity.slot); + return found == bindings.end() ? nullptr : found->second; +} + +std::shared_ptr Party::rosterCreature( + const RosterIdentity &identity, + bool loadIfMissing) { + if (!isRosterAvailable(identity)) { + return nullptr; } + auto creature = rosterCreature(identity); + return creature || !loadIfMissing + ? creature + : _game.materializeRosterCreature(identity); +} - auto creature = _game.newCreature(); - creature->loadFromBlueprint(blueprint); - creature->setFaction(Faction::Friendly1); - creature->setImmortal(true); +std::optional Party::rosterIdentity( + const Creature &creature) const { + for (const auto &[slot, bound] : _npcBindings) { + if (bound.get() == &creature) { + return RosterIdentity {RosterKind::Npc, slot}; + } + } + for (const auto &[slot, bound] : _puppetBindings) { + if (bound.get() == &creature) { + return RosterIdentity {RosterKind::Puppet, slot}; + } + } + return std::nullopt; +} + +std::vector> Party::runtimeObjects() const { + std::vector> result; + std::set seen; + std::function &)> append; + append = [&](const std::shared_ptr &object) { + if (!object || !seen.insert(object.get()).second) return; + result.push_back(object); + for (const auto &item : object->items()) append(item); + if (object->type() != ObjectType::Creature) return; + auto creature = std::static_pointer_cast(object); + for (const auto &[_, item] : creature->equipment()) append(item); + }; + + append(_player); + append(_actualPlayer); + for (const auto &member : _members) append(member.creature); + for (const auto &[_, creature] : _npcBindings) append(creature); + for (const auto &[_, creature] : _puppetBindings) append(creature); + return result; +} + +bool Party::isRosterIdentityValid(const RosterIdentity &identity) const { + if (identity.slot < 0) return false; + if (identity.kind == RosterKind::Puppet) { + return _game.isTSL() && + identity.slot < static_cast(kMaxPuppetCount); + } + const size_t npcCount = _game.isTSL() ? kK2NpcCount : kK1NpcCount; + return identity.slot < static_cast(npcCount); +} + +bool Party::isRosterAvailable(const RosterIdentity &identity) const { + if (!isRosterIdentityValid(identity)) return false; + return identity.kind == RosterKind::Npc + ? _persistedState.npcAvailable[identity.slot] + : _persistedState.puppetAvailable[identity.slot]; +} + +bool Party::setRosterAvailable( + const RosterIdentity &identity, + bool available, + bool selectableWhenAdded) { + if (!isRosterIdentityValid(identity)) return false; + bool ¤t = identity.kind == RosterKind::Npc + ? _persistedState.npcAvailable[identity.slot] + : _persistedState.puppetAvailable[identity.slot]; + if (!available && !current) return false; + current = available; + if (available && selectableWhenAdded) { + bool &selectable = identity.kind == RosterKind::Npc + ? _persistedState.npcSelectable[identity.slot] + : _persistedState.puppetSelectable[identity.slot]; + selectable = true; + } + return true; +} + +bool Party::isRosterSelectable(const RosterIdentity &identity) const { + if (!isRosterAvailable(identity)) return false; + return identity.kind == RosterKind::Npc + ? _persistedState.npcSelectable[identity.slot] + : _persistedState.puppetSelectable[identity.slot]; +} - _availableMembers.insert({npc, std::move(creature)}); +bool Party::setRosterSelectable( + const RosterIdentity &identity, + bool selectable) { + // Retail ignores SetNPCSelectability for invalid or unavailable slots. + if (!isRosterAvailable(identity)) return false; + bool ¤t = identity.kind == RosterKind::Npc + ? _persistedState.npcSelectable[identity.slot] + : _persistedState.puppetSelectable[identity.slot]; + current = selectable; return true; } -bool Party::addAvailableMember(int npc, std::shared_ptr creature) { - if (isMemberAvailable(npc)) { - warn(str(boost::format("Party: NPC %d already exists") % npc)); +bool Party::makeRosterAvailableAndBind( + const RosterIdentity &identity, + const std::shared_ptr &creature) { + if (!creature || !isRosterIdentityValid(identity)) return false; + bool &available = identity.kind == RosterKind::Npc + ? _persistedState.npcAvailable[identity.slot] + : _persistedState.puppetAvailable[identity.slot]; + bool &selectable = identity.kind == RosterKind::Npc + ? _persistedState.npcSelectable[identity.slot] + : _persistedState.puppetSelectable[identity.slot]; + const bool previousAvailable = available; + const bool previousSelectable = selectable; + if (!setRosterAvailable(identity, true) || + !bindRosterCreature(identity, creature)) { + available = previousAvailable; + selectable = previousSelectable; return false; } - - _availableMembers.insert({npc, std::move(creature)}); return true; } -bool Party::removeAvailableMember(int npc) { - auto maybeMember = _availableMembers.find(npc); - if (maybeMember != _availableMembers.end()) { - _availableMembers.erase(maybeMember); - return true; +bool Party::addAvailableRosterRecord( + const RosterIdentity &identity, + const std::shared_ptr &creature) { + if (!creature || !isRosterIdentityValid(identity)) return false; + try { + _game.saveRosterState(identity, *creature); + } catch (const std::exception &e) { + warn("Party: could not persist roster record: " + + std::string(e.what())); + return false; } - return false; + return setRosterAvailable(identity, true); +} + +bool Party::addAvailableRosterRecord( + const RosterIdentity &identity, + const std::string &blueprint) { + if (!isRosterIdentityValid(identity)) return false; + std::shared_ptr creature; + try { + creature = _game.newCreatureFromBlueprint(blueprint); + _game.saveRosterState(identity, *creature); + } catch (const std::exception &e) { + if (creature) _game.destroyRuntimeObjectGraph(creature); + warn("Party: could not add roster blueprint '" + blueprint + + "': " + e.what()); + return false; + } + _game.destroyRuntimeObjectGraph(creature); + return setRosterAvailable(identity, true); } -bool Party::addAvailablePuppet(int puppet, std::shared_ptr creature) { - if (_availablePuppets.count(puppet)) { - warn(str(boost::format("Party: puppet %d already exists") % puppet)); +bool Party::bindRosterCreature( + const RosterIdentity &identity, + const std::shared_ptr &creature) { + if (!creature || !_game.isRuntimeObjectLive(*creature) || + !isRosterAvailable(identity)) { + return false; + } + if (creature == _actualPlayer || + (creature == _player && + (identity.kind != RosterKind::Npc || + _persistedState.controlledNpc != identity.slot))) { + warn("Party: canonical player cannot be claimed by a roster slot"); + return false; + } + + auto existingIdentity = rosterIdentity(*creature); + if (existingIdentity && *existingIdentity != identity) { + warn("Party: creature is already bound to another roster slot"); + return false; + } + auto previous = rosterCreature(identity); + if (identity.kind == RosterKind::Npc && + _persistedState.controlledNpc == identity.slot && _player && + _player != previous && _player != creature) { + warn("Party: controlled roster binding contradicts the current player"); + return false; + } + + if (identity.kind == RosterKind::Npc) { + _npcBindings.insert_or_assign(identity.slot, creature); + if (creature->assignedPuppet() < -1 || + creature->assignedPuppet() >= static_cast(kMaxPuppetCount)) { + creature->setAssignedPuppet(-1); + } + for (auto &member : _members) { + if (member.npc == identity.slot) { + member.creature = creature; + } + } + if (_persistedState.controlledNpc == identity.slot && + (!_player || _player == previous || _player == creature)) { + _player = creature; + } + } else { + if (previous && previous != creature) { + previous->setPuppet(false); + } + creature->setPuppet(true); + _puppetBindings.insert_or_assign(identity.slot, creature); + } + return true; +} + +bool Party::clearRosterCreature( + const RosterIdentity &identity, + const Creature *expected) { + if (!isRosterIdentityValid(identity)) return false; + auto &bindings = identity.kind == RosterKind::Npc + ? _npcBindings + : _puppetBindings; + auto found = bindings.find(identity.slot); + if (found == bindings.end() || + (expected && found->second.get() != expected)) { return false; } - _availablePuppets.emplace(puppet, std::move(creature)); + auto removed = found->second; + bindings.erase(found); + if (identity.kind == RosterKind::Npc) { + _members.erase( + std::remove_if( + _members.begin(), _members.end(), + [&identity, &removed](const Member &member) { + return member.npc == identity.slot || + member.creature == removed; + }), + _members.end()); + if (_persistedState.controlledNpc == identity.slot && + _player == removed) { + _player = _actualPlayer; + _persistedState.controlledNpc = kNpcPlayer; + } + } else { + removed->setPuppet(false); + _persistedState.puppetIds.erase( + std::remove( + _persistedState.puppetIds.begin(), + _persistedState.puppetIds.end(), identity.slot), + _persistedState.puppetIds.end()); + } return true; } -std::shared_ptr Party::getAvailablePuppet(int puppet) const { - auto found = _availablePuppets.find(puppet); - return found == _availablePuppets.end() ? nullptr : found->second; +bool Party::clearRosterCreature(const Creature &creature) { + auto identity = rosterIdentity(creature); + return identity && clearRosterCreature(*identity, &creature); } bool Party::addMember(int npc, std::shared_ptr creature) { - // A creature joining the party derives its XP from the shared party pool. - if (creature) { - creature->setXP(_xp); + if (!creature || isMember(npc) || isMember(*creature)) { + return false; } + if (npc != kNpcPlayer) { + const auto activeCompanions = std::count_if( + _members.begin(), _members.end(), [this](const Member &member) { + return member.npc != kNpcPlayer && + member.npc != _persistedState.controlledNpc; + }); + if (!isRosterAvailable({RosterKind::Npc, npc}) || + (npc != _persistedState.controlledNpc && activeCompanions >= 2) || + !bindRosterCreature({RosterKind::Npc, npc}, creature)) { + return false; + } + } + // A creature joining the party derives its XP from the shared party pool. + creature->setXP(_xp); Member member; member.npc = npc; member.creature = creature; _members.push_back(std::move(member)); + if (_game.isTSL() && npc != kNpcPlayer && + npc != _persistedState.controlledNpc && + creature->assignedPuppet() >= 0) { + const int puppet = creature->assignedPuppet(); + try { + if (auto runtimePuppet = getAvailablePuppet(puppet, true)) { + addPuppet(puppet, runtimePuppet); + } + } catch (const std::exception &e) { + warn("Party: could not materialize assigned puppet " + + std::to_string(puppet) + ": " + e.what()); + } + } + return true; } @@ -157,11 +449,14 @@ void Party::reset() { } void Party::retireRuntimeSession() { + for (const auto &[_, puppet] : _puppetBindings) { + if (puppet) puppet->setPuppet(false); + } _player.reset(); _actualPlayer.reset(); - _availableMembers.clear(); + _npcBindings.clear(); _members.clear(); - _availablePuppets.clear(); + _puppetBindings.clear(); } void Party::clear() { @@ -241,13 +536,19 @@ void Party::onLeaderChanged() { } std::shared_ptr Party::getAvailableMember(int npc) const { - auto member = _availableMembers.find(npc); - if (member == _availableMembers.end()) { + if (!isMemberAvailable(npc)) { return nullptr; } + auto member = _npcBindings.find(npc); + if (member == _npcBindings.end()) return nullptr; return member->second; } +std::shared_ptr Party::getAvailableMember( + int npc, bool loadIfMissing) { + return rosterCreature({RosterKind::Npc, npc}, loadIfMissing); +} + std::shared_ptr Party::getMember(int index) const { return _members.size() > index ? _members[index].creature : nullptr; } @@ -282,13 +583,104 @@ bool Party::isMember(int npc) const { } bool Party::isMemberAvailable(int npc) const { - return _availableMembers.count(npc) != 0; + return isRosterAvailable({RosterKind::Npc, npc}); +} + +bool Party::addPuppet( + int puppet, + const std::shared_ptr &creature) { + if (!isRosterAvailable({RosterKind::Puppet, puppet}) || !creature || + isPuppet(puppet) || _persistedState.puppetIds.size() >= 2 || + !bindRosterCreature({RosterKind::Puppet, puppet}, creature)) { + return false; + } + creature->setPuppet(true); + _persistedState.puppetIds.push_back(puppet); + return true; +} + +bool Party::removePuppet(int puppet) { + auto found = std::find( + _persistedState.puppetIds.begin(), + _persistedState.puppetIds.end(), puppet); + if (found == _persistedState.puppetIds.end()) return false; + if (auto creature = rosterCreature({RosterKind::Puppet, puppet})) { + try { + // Retail RemovePuppet snapshots the live representation before + // KillPUPObject invalidates its transient object binding. + _game.saveRosterState({RosterKind::Puppet, puppet}, *creature); + } catch (const std::exception &e) { + warn("Party: could not persist puppet before removal: " + + std::string(e.what())); + return false; + } + creature->setPuppet(false); + } + _persistedState.puppetIds.erase(found); + return true; +} + +bool Party::isPuppet(int puppet) const { + return std::find( + _persistedState.puppetIds.begin(), + _persistedState.puppetIds.end(), puppet) != + _persistedState.puppetIds.end(); +} + +bool Party::assignPuppet(int puppet, int npc) { + if (!isRosterAvailable({RosterKind::Puppet, puppet}) || + !isRosterAvailable({RosterKind::Npc, npc}) || + !rosterCreature({RosterKind::Npc, npc})) { + return false; + } + auto creature = rosterCreature({RosterKind::Npc, npc}); + creature->setAssignedPuppet(puppet); + return true; +} + +std::optional Party::assignedNpcForPuppet(int puppet) const { + for (const auto &member : _members) { + if (member.npc != kNpcPlayer && member.creature && + member.creature->assignedPuppet() == puppet) { + return member.npc; + } + } + return std::nullopt; +} + +std::shared_ptr Party::puppetOwner(int puppet) const { + if (puppet < 0 || puppet >= static_cast(kMaxPuppetCount)) { + return nullptr; + } + for (const auto &member : _members) { + if (member.npc != kNpcPlayer && member.creature && + member.creature->assignedPuppet() == puppet) { + return member.creature; + } + } + return _player && _player->assignedPuppet() == puppet ? _player : nullptr; } bool Party::isMember(const Object &object) const { for (auto &member : _members) { - if (member.creature->id() == object.id()) + if (member.creature.get() == &object) + return true; + } + return false; +} + +bool Party::isRetainedRuntimeRepresentation(const Creature &creature) const { + if (_player.get() == &creature || _actualPlayer.get() == &creature) { + return true; + } + if (isMember(creature)) { + return true; + } + for (int puppet : _persistedState.puppetIds) { + auto found = _puppetBindings.find(puppet); + if (found != _puppetBindings.end() && found->second.get() == &creature) { return true; + } } return false; } @@ -341,6 +733,19 @@ void Party::setPlayer(const std::shared_ptr &player) { } void Party::setControlledMember(int npc, const std::shared_ptr &creature) { + if (!creature || + (npc != kNpcPlayer && + !bindRosterCreature({RosterKind::Npc, npc}, creature))) { + warn("Party: controlled creature cannot bind roster slot " + + std::to_string(npc)); + return; + } + if (npc != kNpcPlayer && isMember(npc)) { + // Retail removes an incoming companion from the two-member array + // before it becomes the controlled player, including its active + // assigned-puppet runtime representation. + removeMember(npc); + } // However the incoming creature was represented before, it ends up in the // leading slot once and only once. _members.erase( @@ -368,14 +773,94 @@ void Party::setControlledMember(int npc, const std::shared_ptr &creatu } bool Party::removeMember(int npc) { + if (npc == kNpcPlayer || + (npc == _persistedState.controlledNpc && + _player == rosterCreature({RosterKind::Npc, npc}))) { + return false; + } auto maybeMember = std::find_if(_members.begin(), _members.end(), [&npc](auto &member) { return member.npc == npc; }); if (maybeMember != _members.end()) { + const auto creature = maybeMember->creature; + const int puppet = creature ? creature->assignedPuppet() : -1; + if (creature) { + try { + // Retail RemoveMember saves the current bound NPC before its + // runtime membership changes. Reone retains the binding, but + // keeping the detached snapshot current preserves the same + // explicit PartyTable repository semantics. + _game.saveRosterState({RosterKind::Npc, npc}, *creature); + } catch (const std::exception &e) { + warn("Party: could not persist NPC before removal: " + + std::string(e.what())); + return false; + } + } + if (_game.isTSL() && puppet >= 0 && isPuppet(puppet)) { + if (!removePuppet(puppet)) return false; + _game.killRosterCreature({RosterKind::Puppet, puppet}); + } _members.erase(maybeMember); return true; } return false; } +bool Party::removeMemberToBase(int npc) { + const RosterIdentity identity {RosterKind::Npc, npc}; + if (!_game.isTSL() || !isRosterAvailable(identity)) { + return false; + } + + auto creature = rosterCreature(identity); + if (!creature) { + return true; + } + + // Retail RemoveMember returns early when the active member table is + // empty. RemoveNPCFromPartyToBase still proceeds to KillNPCObject, so the + // caller deliberately keeps that final retirement separate. + if (_members.empty()) { + return true; + } + + // K2 removes transient effects before writing AVAILNPCn, but retains the + // supported action queue in the detached record. Runtime retirement later + // clears the old live execution state through the Area/C4 boundary. + creature->clearAllEffects(); + + const int puppet = creature->assignedPuppet(); + if (puppet >= 0 && isPuppet(puppet)) { + if (auto puppetCreature = rosterCreature( + {RosterKind::Puppet, puppet})) { + puppetCreature->clearAllEffects(); + } + if (!removePuppet(puppet)) { + return false; + } + _game.killRosterCreature({RosterKind::Puppet, puppet}); + } + + try { + _game.saveRosterState(identity, *creature); + } catch (const std::exception &e) { + warn("Party: could not persist NPC before base removal: " + + std::string(e.what())); + return false; + } + + _members.erase( + std::remove_if( + _members.begin(), _members.end(), + [npc, &creature](const Member &member) { + return member.npc == npc || member.creature == creature; + }), + _members.end()); + if (_members.empty()) { + _solo = false; + } + return true; +} + void Party::defaultMembers(std::string &member1, std::string &member2, std::string &member3) const { if (_game.isTSL()) { member1 = kBlueprintResRefAtton; diff --git a/src/libs/game/savedruntime.cpp b/src/libs/game/savedruntime.cpp index 89d8313d1..c702fb91f 100644 --- a/src/libs/game/savedruntime.cpp +++ b/src/libs/game/savedruntime.cpp @@ -27,8 +27,20 @@ namespace reone { namespace game { +std::shared_ptr SavedObjectReference::boundObject() const { + return _object.resolve(); +} + namespace { +template +struct Overloaded : Visitors... { + using Visitors::operator()...; +}; + +template +Overloaded(Visitors...) -> Overloaded; + SavedField savedFieldFromGff(const resource::Gff::Field &field) { SavedField result; result.type = field.type; @@ -116,7 +128,9 @@ SavedLocationValue savedLocationFromGff(const resource::Gff &gff) { return result; } -SavedScriptEvent savedScriptEventFromGff(const resource::Gff &gff) { +SavedScriptEvent savedScriptEventFromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { SavedScriptEvent result; result.type = static_cast(gff.getUint("EventType")); for (const auto &item : gff.getList("IntList")) { @@ -129,18 +143,25 @@ SavedScriptEvent savedScriptEventFromGff(const resource::Gff &gff) { result.strings.push_back(item->getString("Parameter")); } for (const auto &item : gff.getList("ObjectList")) { - result.objects.push_back(SavedObjectReference {item->getUint("Parameter")}); + result.objects.push_back(SavedObjectReference::fromSerializedId( + item->getUint("Parameter"), identityContext)); } return result; } -SavedSpellImpact savedSpellImpactFromGff(const resource::Gff &gff) { +SavedSpellImpact savedSpellImpactFromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { SavedSpellImpact result; result.spellId = gff.getInt("SpellId"); - result.caster.id = gff.getUint("CasterId"); - result.target.id = gff.getUint("TargetId"); - result.area.id = gff.getUint("AreaId"); - result.item.id = gff.getUint("ItemId"); + result.caster = SavedObjectReference::fromSerializedId( + gff.getUint("CasterId"), identityContext); + result.target = SavedObjectReference::fromSerializedId( + gff.getUint("TargetId"), identityContext); + result.area = SavedObjectReference::fromSerializedId( + gff.getUint("AreaId"), identityContext); + result.item = SavedObjectReference::fromSerializedId( + gff.getUint("ItemId"), identityContext); result.script = gff.getString("Script"); result.targetPosition = glm::vec3( gff.getFloat("TargetPosX"), @@ -150,9 +171,12 @@ SavedSpellImpact savedSpellImpactFromGff(const resource::Gff &gff) { return result; } -SavedBodyBag savedBodyBagFromGff(const resource::Gff &gff) { +SavedBodyBag savedBodyBagFromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { SavedBodyBag result; - result.object.id = gff.getUint("BodyBagId"); + result.object = SavedObjectReference::fromSerializedId( + gff.getUint("BodyBagId"), identityContext); result.position = glm::vec3( gff.getFloat("PositionX"), gff.getFloat("PositionY"), @@ -160,12 +184,39 @@ SavedBodyBag savedBodyBagFromGff(const resource::Gff &gff) { return result; } -SavedTalentValue savedTalentFromGff(const resource::Gff &gff) { +SavedCombatAttack savedCombatAttackFromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + SavedCombatAttack result; + result.data = SavedStruct::fromGff(gff); + result.reactionObject = SavedObjectReference::fromSerializedId( + gff.getUint("ReactObject"), identityContext); + result.ammoItem = SavedObjectReference::fromSerializedId( + gff.getUint("AmmoItem"), identityContext); + return result; +} + +SavedFeedbackMessage savedFeedbackMessageFromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { + SavedFeedbackMessage result; + result.data = SavedStruct::fromGff(gff); + for (const auto &item : gff.getList("ObjectIDList")) { + result.objects.push_back(SavedObjectReference::fromSerializedId( + item->getUint("ObjectValue"), identityContext)); + } + return result; +} + +SavedTalentValue savedTalentFromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { SavedTalentValue result; result.id = gff.getInt("ID"); result.type = gff.getInt("Type"); result.multiClass = static_cast(gff.getUint("MultiClass")); - result.item.id = gff.getUint("Item"); + result.item = SavedObjectReference::fromSerializedId( + gff.getUint("Item"), identityContext); // Retail passes "ItemPropertyIndex" to a 16-byte GFF label API; the // on-wire label is therefore truncated to this value. result.itemPropertyIndex = gff.getInt("ItemPropertyInde"); @@ -263,7 +314,7 @@ std::optional decodeMoveToPoint(const SavedActionRecord &recor bool bindReference(const Game &game, SavedObjectReference &reference, bool &allBound) { if (reference.isInvalid()) { - return false; + return true; } bool bound = game.bindSavedObjectReference(reference); allBound = allBound && bound; @@ -282,7 +333,9 @@ SavedStruct SavedStruct::fromGff(const resource::Gff &gff) { return result; } -SerializedScriptSituation SerializedScriptSituation::fromGff(const resource::Gff &gff) { +SerializedScriptSituation SerializedScriptSituation::fromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { SerializedScriptSituation result; result.codeSize = gff.getInt("CodeSize"); result.code = gff.getData("Code"); @@ -318,17 +371,20 @@ SerializedScriptSituation SerializedScriptSituation::fromGff(const resource::Gff value.payload = item->getString("Value"); break; case SavedVmStackType::Object: - value.payload = SavedObjectReference {item->getUint("Value")}; + value.payload = SavedObjectReference::fromSerializedId( + item->getUint("Value"), identityContext); break; case SavedVmStackType::Effect: { auto structure = item->findStruct("GameDefinedStrct"); - value.payload = structure ? SavedVmStackPayload(EffectInstance::fromGff(*structure)) + value.payload = structure ? SavedVmStackPayload(EffectInstance::fromGff( + *structure, identityContext)) : SavedVmStackPayload(unsupportedPayload(*item)); break; } case SavedVmStackType::Event: { auto structure = item->findStruct("GameDefinedStrct"); - value.payload = structure ? SavedVmStackPayload(savedScriptEventFromGff(*structure)) + value.payload = structure ? SavedVmStackPayload(savedScriptEventFromGff( + *structure, identityContext)) : SavedVmStackPayload(unsupportedPayload(*item)); break; } @@ -340,7 +396,8 @@ SerializedScriptSituation SerializedScriptSituation::fromGff(const resource::Gff } case SavedVmStackType::Talent: { auto structure = item->findStruct("GameDefinedStrct"); - value.payload = structure ? SavedVmStackPayload(savedTalentFromGff(*structure)) + value.payload = structure ? SavedVmStackPayload(savedTalentFromGff( + *structure, identityContext)) : SavedVmStackPayload(unsupportedPayload(*item)); break; } @@ -355,6 +412,7 @@ SerializedScriptSituation SerializedScriptSituation::fromGff(const resource::Gff bool SerializedScriptSituation::bindObjectReferences(const Game &game) { if (_runtimeSession && *_runtimeSession != game._runtimeSessionGeneration) { + _referencesBound = false; return false; } if (!_runtimeSession) { @@ -363,28 +421,42 @@ bool SerializedScriptSituation::bindObjectReferences(const Game &game) { bool allBound = true; for (auto &entry : stack) { - if (auto reference = std::get_if(&entry.payload)) { - bindReference(game, *reference, allBound); - } else if (auto effect = std::get_if(&entry.payload)) { - if (effect->creatorId != kSavedRuntimeInvalidObjectId) { - allBound = game.bindEffectCreator(*effect) && allBound; - } - } else if (auto event = std::get_if(&entry.payload)) { - for (auto &reference : event->objects) { - bindReference(game, reference, allBound); - } - } else if (auto talent = std::get_if(&entry.payload)) { - bindReference(game, talent->item, allBound); - } - } + std::visit( + Overloaded { + [](UnsupportedSavedPayload &) {}, + [](int32_t &) {}, + [](float &) {}, + [](std::string &) {}, + [&game, &allBound](SavedObjectReference &reference) { + bindReference(game, reference, allBound); + }, + [&game, &allBound](EffectInstance &effect) { + allBound = game.bindEffectCreator(effect) && allBound; + }, + [&game, &allBound](SavedScriptEvent &event) { + for (auto &reference : event.objects) { + bindReference(game, reference, allBound); + } + }, + [](SavedLocationValue &) {}, + [&game, &allBound](SavedTalentValue &talent) { + bindReference(game, talent.item, allBound); + }, + }, + entry.payload); + } + _referencesBound = allBound; return allBound; } bool SerializedScriptSituation::isBoundToCurrentRuntimeSession(const Game &game) const { - return _runtimeSession && *_runtimeSession == game._runtimeSessionGeneration; + return _referencesBound && _runtimeSession && + *_runtimeSession == game._runtimeSessionGeneration; } -SavedActionParameter SavedActionParameter::fromGff(const resource::Gff &gff) { +SavedActionParameter SavedActionParameter::fromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { SavedActionParameter result; result.type = gff.getUint("Type"); switch (static_cast(result.type)) { @@ -395,14 +467,17 @@ SavedActionParameter SavedActionParameter::fromGff(const resource::Gff &gff) { result.payload = gff.getFloat("Value"); break; case SavedActionParameterType::Object: - result.payload = SavedObjectReference {gff.getUint("Value")}; + result.payload = SavedObjectReference::fromSerializedId( + gff.getUint("Value"), identityContext); break; case SavedActionParameterType::String: result.payload = gff.getString("Value"); break; case SavedActionParameterType::ScriptSituation: { auto value = gff.findStruct("Value"); - result.payload = value ? SavedActionParameterPayload(SerializedScriptSituation::fromGff(*value)) + result.payload = value ? SavedActionParameterPayload( + SerializedScriptSituation::fromGff( + *value, identityContext)) : SavedActionParameterPayload(unsupportedPayload(gff)); break; } @@ -423,13 +498,16 @@ bool SavedActionParameter::bindObjectReferences(const Game &game) { return true; } -SavedActionRecord SavedActionRecord::fromGff(const resource::Gff &gff) { +SavedActionRecord SavedActionRecord::fromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { SavedActionRecord result; result.actionId = gff.getUint("ActionId"); result.groupActionId = static_cast(gff.getUint("GroupActionId")); result.declaredParameterCount = static_cast(gff.getUint("NumParams")); for (const auto ¶meter : gff.getList("Paramaters")) { - result.parameters.push_back(SavedActionParameter::fromGff(*parameter)); + result.parameters.push_back(SavedActionParameter::fromGff( + *parameter, identityContext)); } result.unsupportedFields = collectUnsupportedFields( gff, @@ -665,20 +743,28 @@ bool SavedActionRecord::bindObjectReferences(const Game &game) { return allBound; } -SavedActionQueue SavedActionQueue::fromGff(const resource::Gff &gff, const std::string &label) { +SavedActionQueue SavedActionQueue::fromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext, + const std::string &label) { SavedActionQueue result; for (const auto &action : gff.getList(label)) { - result.actions.push_back(SavedActionRecord::fromGff(*action)); + result.actions.push_back(SavedActionRecord::fromGff( + *action, identityContext)); } return result; } -SavedEventRecord SavedEventRecord::fromGff(const resource::Gff &gff) { +SavedEventRecord SavedEventRecord::fromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext) { SavedEventRecord result; result.day = gff.getUint("Day"); result.time = gff.getUint("Time"); - result.object.id = gff.getUint("ObjectId"); - result.caller.id = gff.getUint("CallerId"); + result.object = SavedObjectReference::fromSerializedId( + gff.getUint("ObjectId"), identityContext); + result.caller = SavedObjectReference::fromSerializedId( + gff.getUint("CallerId"), identityContext); result.eventId = gff.getUint("EventId"); result.unsupportedFields = collectUnsupportedFields( gff, @@ -691,17 +777,19 @@ SavedEventRecord SavedEventRecord::fromGff(const resource::Gff &gff) { } switch (static_cast(result.eventId)) { case SavedEventType::Timed: - result.payload = SerializedScriptSituation::fromGff(*data); + result.payload = SerializedScriptSituation::fromGff( + *data, identityContext); break; case SavedEventType::RemoveFromArea: result.payload = SavedBytePayload {static_cast(data->getUint("Value"))}; break; case SavedEventType::ApplyEffect: case SavedEventType::RemoveEffect: - result.payload = EffectInstance::fromGff(*data); + result.payload = EffectInstance::fromGff(*data, identityContext); break; case SavedEventType::SpellImpact: - result.payload = savedSpellImpactFromGff(*data); + case SavedEventType::ItemOnHitSpellImpact: + result.payload = savedSpellImpactFromGff(*data, identityContext); break; case SavedEventType::PlayAnimation: case SavedEventType::ControllerRumble: @@ -710,13 +798,23 @@ SavedEventRecord SavedEventRecord::fromGff(const resource::Gff &gff) { case SavedEventType::SignalEvent: case SavedEventType::SummonCreature: case SavedEventType::AreaTransition: - result.payload = savedScriptEventFromGff(*data); + result.payload = savedScriptEventFromGff(*data, identityContext); break; case SavedEventType::SpawnBodyBag: - result.payload = savedBodyBagFromGff(*data); + result.payload = savedBodyBagFromGff(*data, identityContext); + break; + case SavedEventType::OnMeleeAttacked: + case SavedEventType::BroadcastSafeProjectile: + result.payload = savedCombatAttackFromGff(*data, identityContext); break; case SavedEventType::BroadcastAoo: - result.payload = SavedDwordPayload {data->getUint("Value")}; + result.payload = SavedBroadcastAoo { + SavedObjectReference::fromSerializedId( + data->getUint("Value"), identityContext)}; + break; + case SavedEventType::FeedbackMessage: + result.payload = savedFeedbackMessageFromGff( + *data, identityContext); break; default: result.payload = unsupportedPayload(*data); @@ -754,9 +852,7 @@ bool SavedEventRecord::bindObjectReferences(const Game &game) { if (auto situation = std::get_if(&payload)) { allBound = situation->bindObjectReferences(game) && allBound; } else if (auto effect = std::get_if(&payload)) { - if (effect->creatorId != kSavedRuntimeInvalidObjectId) { - allBound = game.bindEffectCreator(*effect) && allBound; - } + allBound = game.bindEffectCreator(*effect) && allBound; } else if (auto spell = std::get_if(&payload)) { bindReference(game, spell->caster, allBound); bindReference(game, spell->target, allBound); @@ -768,14 +864,27 @@ bool SavedEventRecord::bindObjectReferences(const Game &game) { } } else if (auto bodyBag = std::get_if(&payload)) { bindReference(game, bodyBag->object, allBound); + } else if (auto broadcast = std::get_if(&payload)) { + bindReference(game, broadcast->target, allBound); + } else if (auto combat = std::get_if(&payload)) { + bindReference(game, combat->reactionObject, allBound); + bindReference(game, combat->ammoItem, allBound); + } else if (auto feedback = std::get_if(&payload)) { + for (auto &reference : feedback->objects) { + bindReference(game, reference, allBound); + } } return allBound; } -SavedEventQueue SavedEventQueue::fromGff(const resource::Gff &gff, const std::string &label) { +SavedEventQueue SavedEventQueue::fromGff( + const resource::Gff &gff, + const SerializedIdentityContext &identityContext, + const std::string &label) { SavedEventQueue result; for (const auto &event : gff.getList(label)) { - result.events.push_back(SavedEventRecord::fromGff(*event)); + result.events.push_back(SavedEventRecord::fromGff( + *event, identityContext)); } return result; } diff --git a/src/libs/game/savegame.cpp b/src/libs/game/savegame.cpp index 4694310e9..a4817b228 100644 --- a/src/libs/game/savegame.cpp +++ b/src/libs/game/savegame.cpp @@ -534,18 +534,19 @@ SaveResult Game::executeSave(SaveRequest request) { return result; } -bool Game::storeCurrentModuleForTransition() { +std::shared_ptr +Game::prepareCurrentModuleWorkingState() { auto committed = _services.resource.director.committedSaveWorkingState(); if (!committed || !_module) { error("Module transition has no committed save working state"); - return false; + return nullptr; } auto captured = _saveSeams.captureModule ? _saveSeams.captureModule(*this, _module->name()) : ModuleSnapshotBuilder(*this, _module->name()).build(); if (!captured) { error("Unable to snapshot source module for transition: " + captured.message); - return false; + return nullptr; } try { auto candidate = resource::SaveWorkingStateCandidate::fromCommitted( @@ -555,17 +556,41 @@ bool Game::storeCurrentModuleForTransition() { captured.snapshot->archiveBytes, resource::ResourceId( captured.snapshot->target.resRef, resource::ResType::Rsv)); + + // Retail UpdateMembers(0) refreshes every bound NPC/PUP record while + // leaving its ActionList intact. Keep those detached snapshots in the + // same candidate as the source module: a rejected transition changes + // neither, while a committed transition adopts both together. + const size_t npcCount = isTSL() ? Party::kK2NpcCount + : Party::kK1NpcCount; + for (size_t npc = 0; npc < npcCount; ++npc) { + auto creature = _party.rosterCreature( + {RosterKind::Npc, static_cast(npc)}); + if (!creature) continue; + candidate.put( + {"availnpc" + std::to_string(npc), resource::ResType::Utc}, + SaveWideSnapshotBuilder::availableNpcRecord(*this, *creature)); + } + if (isTSL()) { + for (size_t puppet = 0; puppet < Party::kMaxPuppetCount; ++puppet) { + auto creature = _party.rosterCreature( + {RosterKind::Puppet, static_cast(puppet)}); + if (!creature) continue; + candidate.put( + {"availpup" + std::to_string(puppet), resource::ResType::Utc}, + SaveWideSnapshotBuilder::availableNpcRecord(*this, *creature)); + } + } auto validation = candidate.validate(); if (!validation) { error("Unable to validate transition working state: " + validation.errors.front()); - return false; + return nullptr; } - _services.resource.director.adoptSaveWorkingState(candidate.freeze()); - return true; + return candidate.freeze(); } catch (const std::exception &e) { - error("Unable to commit transition working state: " + std::string(e.what())); - return false; + error("Unable to prepare transition working state: " + std::string(e.what())); + return nullptr; } } diff --git a/src/libs/game/saveprovenance.cpp b/src/libs/game/saveprovenance.cpp index 1ba2fbdbb..f4a642ad4 100644 --- a/src/libs/game/saveprovenance.cpp +++ b/src/libs/game/saveprovenance.cpp @@ -2,6 +2,8 @@ #include "reone/game/saveprovenance.h" +#include + namespace reone { namespace game { @@ -40,6 +42,86 @@ void removeSaveField(resource::Gff &record, std::string_view label) { fields.end()); } +std::vector collectSerializedObjectIdClaims( + const resource::Gff &root, + const SerializedIdentityContext &context, + SerializedGraphRoot graphRoot) { + if (!context.hasAuthoritativeObjectIds()) { + return {}; + } + + std::vector result; + auto add = [&](const resource::Gff &record, std::string path) { + uint32_t id = 0; + if (record.readDword(id, "ObjectId")) { + result.push_back({id, std::move(path)}); + } + }; + auto addItems = [&](const resource::Gff &owner, + const std::string &ownerPath, + bool includeInventory) { + const auto equipped = owner.getList("Equip_ItemList"); + for (size_t index = 0; index < equipped.size(); ++index) { + add(*equipped[index], ownerPath + "/Equip_ItemList[" + + std::to_string(index) + "]"); + } + if (!includeInventory) { + return; + } + const auto inventory = owner.getList("ItemList"); + for (size_t index = 0; index < inventory.size(); ++index) { + add(*inventory[index], ownerPath + "/ItemList[" + + std::to_string(index) + "]"); + } + }; + auto addCreatureList = [&](const char *label, const char *rootName) { + const auto creatures = root.getList(label); + for (size_t index = 0; index < creatures.size(); ++index) { + std::string path = std::string(rootName) + "/" + label + "[" + + std::to_string(index) + "]"; + add(*creatures[index], path); + addItems(*creatures[index], path, true); + } + }; + + switch (graphRoot) { + case SerializedGraphRoot::ModuleIfo: { + const auto areas = root.getList("Mod_Area_list"); + for (size_t index = 0; index < areas.size(); ++index) { + add(*areas[index], "ifo/Mod_Area_list[" + std::to_string(index) + "]"); + } + const auto players = root.getList("Mod_PlayerList"); + for (size_t index = 0; index < players.size(); ++index) { + std::string path = "ifo/Mod_PlayerList[" + std::to_string(index) + "]"; + add(*players[index], path); + addItems(*players[index], path, false); + } + addCreatureList("Creature List", "ifo"); + break; + } + case SerializedGraphRoot::AreaGit: { + addCreatureList("Creature List", "git"); + static constexpr std::array objectLists { + "Door List", "Placeable List", "TriggerList", "Trigger List", + "Encounter List", "StoreList", "WaypointList", "SoundList", "List"}; + for (const char *label : objectLists) { + const auto objects = root.getList(label); + for (size_t index = 0; index < objects.size(); ++index) { + std::string path = "git/" + std::string(label) + "[" + + std::to_string(index) + "]"; + add(*objects[index], path); + if (std::string_view(label) == "Placeable List" || + std::string_view(label) == "StoreList") { + addItems(*objects[index], path, true); + } + } + } + break; + } + } + return result; +} + void SaveResourceShadows::capture(SaveResourceKey key, const resource::Gff &source) { _shadows.insert_or_assign(std::move(key), SaveGffShadow::capture(source)); } diff --git a/src/libs/game/savewidesnapshot.cpp b/src/libs/game/savewidesnapshot.cpp index 378acf5c6..a0a7809ea 100644 --- a/src/libs/game/savewidesnapshot.cpp +++ b/src/libs/game/savewidesnapshot.cpp @@ -21,6 +21,7 @@ #include "reone/resource/format/gffreader.h" #include "reone/resource/format/gffwriter.h" #include "reone/resource/gff.h" +#include "reone/resource/director.h" #include "reone/resource/parser/gff/gvt.h" #include "reone/resource/saveworkingstate.h" #include "reone/system/binarywriter.h" @@ -567,7 +568,13 @@ std::shared_ptr SaveWideSnapshotBuilder::buildNfo() const { ByteBuffer SaveWideSnapshotBuilder::availableNpcRecord( const Game &game, const Creature &creature) { ModuleSnapshotBuilder records(game, {}); - return encode("UTC ", *records.writeCreature(creature, 0xffffffff, std::nullopt)); + return encode( + "UTC ", + *records.writeCreature( + creature, + 0xffffffff, + std::nullopt, + SerializedIdentityContext::detachedRecord("available-npc-record"))); } SaveWideSnapshotResult SaveWideSnapshotBuilder::build() const noexcept { @@ -632,10 +639,29 @@ SaveWideSnapshotResult SaveWideSnapshotBuilder::build() const noexcept { const std::shared_ptr &creature, bool sharedInventoryOwner = false) { if (!creature) { - throw ValidationException("available save-wide creature is missing"); + // Retail keeps the last AVAILNPC/AVAILPUP record when its + // transient runtime object is killed. An available but unbound + // slot therefore persists that detached record unchanged. + auto working = + _game._services.resource.director.committedSaveWorkingState(); + auto prior = working + ? working->find({name, ResType::Utc}) + : std::nullopt; + if (!prior) { + throw ValidationException( + "available roster slot has neither binding nor detached record"); + } + add( + {name, ResType::Utc}, + readGff(prior->data), + "UTC ", false); + return; } auto utc = records.writeCreature( - *creature, 0xffffffff, std::nullopt); + *creature, + 0xffffffff, + std::nullopt, + SerializedIdentityContext::detachedRecord(name + ".utc")); // Reone models the retail party repository as the actual player's // non-equipped ItemList. inventory.res owns that topology; pc.utc // retains equipment but must not duplicate the shared repository. diff --git a/src/libs/game/script/routine/impl/main.cpp b/src/libs/game/script/routine/impl/main.cpp index 24951d23d..2269a668b 100644 --- a/src/libs/game/script/routine/impl/main.cpp +++ b/src/libs/game/script/routine/impl/main.cpp @@ -21,6 +21,7 @@ #include "reone/game/action/jumptolocation.h" #include "reone/game/action/jumptoobject.h" #include "reone/game/effect/visual.h" +#include "reone/game/equipmentrules.h" #include "reone/game/event.h" #include "reone/game/game.h" #include "reone/game/object/door.h" @@ -236,7 +237,7 @@ static Variable SwitchPlayerCharacter(const std::vector &args, const R if (party.controlledNpc() == nNPC) { return Variable::ofInt(1); } - creature = party.getAvailableMember(nNPC); + creature = party.getAvailableMember(nNPC, true); } if (!creature) { warn("Party: NPC not found: " + std::to_string(nNPC)); @@ -249,14 +250,12 @@ static Variable SwitchPlayerCharacter(const std::vector &args, const R auto module = ctx.game.module(); auto area = module ? module->area() : nullptr; - if (area) { - area->unloadParty(); - } - party.setControlledMember(nNPC, creature); if (area) { - area->loadParty(position, facing); + // Retail changes the controlled object inside the existing Area. Do + // not invoke module-boundary retirement merely to place the new PC. + area->placeControlledCreature(creature, position, facing); area->onPartyLeaderMoved(true); area->update3rdPersonCameraFacing(); } @@ -1861,7 +1860,7 @@ static Variable SetListenPattern(const std::vector &args, const Routin auto creature = checkCreature(oObject); // Execute - ctx.game.module()->area()->messageBus().addListener(creature->id(), sPattern, nNumber); + ctx.game.module()->area()->messageBus().addListener(creature, sPattern, nNumber); return Variable::ofNull(); } @@ -2500,6 +2499,18 @@ static Variable DestroyObject(const std::vector &args, const RoutineCo // Transform // Execute + if (auto item = dyn_cast(oDestroy)) { + uint32_t ownerId = item->owner(); + if (ownerId != 0 && ownerId != kObjectInvalid) { + if (auto owner = ctx.game.getObjectById(ownerId)) { + if (auto creature = dyn_cast(owner); + creature && item->isEquipped()) { + creature->takeEquippedItem(item); + } + owner->removeItemStack(item); + } + } + } ctx.game.module()->area()->destroyObject(*oDestroy); return Variable::ofNull(); } @@ -2726,19 +2737,7 @@ static Variable GiveItem(const std::vector &args, const RoutineContext auto item = checkItem(oItem); // Execute - uint32_t owner = item->owner(); - if (owner && owner != kObjectInvalid) { - auto object = ctx.game.getObjectById(owner); - if (object) { - if (auto creature = dyn_cast(object)) { - if (item->isEquipped()) { - creature->unequip(item); - } - } - object->removeItemStack(item); - } - } - oGiveTo->addItem(item); + transferItemTo(ctx.game, item, *oGiveTo); return Variable::ofNull(); } @@ -2886,7 +2885,7 @@ static Variable GetObjectSeen(const std::vector &args, const RoutineCo auto source = checkCreature(oSource); // Execute - bool seen = source->perception().seen.count(oTarget->id()) > 0; + bool seen = source->perception().sees(oTarget->id()); return Variable::ofInt(static_cast(seen)); } @@ -5628,10 +5627,11 @@ static Variable AddAvailableNPCByObject(const std::vector &args, const auto nNPC = getInt(args, 0); auto oCreature = getObject(args, 1, ctx); - // Transform + auto creature = checkCreature(oCreature); // Execute - throw RoutineNotImplementedException("AddAvailableNPCByObject"); + return Variable::ofInt(ctx.game.party().addAvailableRosterRecord( + {RosterKind::Npc, nNPC}, creature)); } static Variable RemoveAvailableNPC(const std::vector &args, const RoutineContext &ctx) { @@ -5675,7 +5675,7 @@ static Variable SpawnAvailableNPC(const std::vector &args, const Routi auto lPosition = getLocationArgument(args, 1); // Execute - auto member = ctx.game.party().getAvailableMember(nNPC); + auto member = ctx.game.party().getAvailableMember(nNPC, true); if (!member) { return Variable::ofObject(kObjectInvalid); } @@ -5687,8 +5687,13 @@ static Variable SpawnAvailableNPC(const std::vector &args, const Routi member->setPosition(lPosition->position()); member->setFacing(lPosition->facing()); area->landObject(*member); - area->add(member); - member->runSpawnScript(); + const bool alreadyResident = std::find( + area->objects().begin(), area->objects().end(), member) != + area->objects().end(); + if (!alreadyResident) { + area->add(member); + member->runSpawnScript(); + } return Variable::ofObject(member->id()); } @@ -5754,20 +5759,22 @@ static Variable SetNPCSelectability(const std::vector &args, const Rou auto nNPC = getInt(args, 0); auto nSelectability = getInt(args, 1); - // Transform - // Execute - throw RoutineNotImplementedException("SetNPCSelectability"); + ctx.game.party().setRosterSelectable( + {RosterKind::Npc, nNPC}, nSelectability != 0); + return Variable::ofNull(); } static Variable GetNPCSelectability(const std::vector &args, const RoutineContext &ctx) { // Load auto nNPC = getInt(args, 0); - // Transform - // Execute - throw RoutineNotImplementedException("GetNPCSelectability"); + const RosterIdentity identity {RosterKind::Npc, nNPC}; + if (!ctx.game.party().isRosterAvailable(identity)) { + return Variable::ofInt(-1); + } + return Variable::ofInt(ctx.game.party().isRosterSelectable(identity)); } static Variable ClearAllEffects(const std::vector &args, const RoutineContext &ctx) { @@ -6298,12 +6305,19 @@ static Variable CreateItemOnFloor(const std::vector &args, const Routi static Variable SetAvailableNPCId(const std::vector &args, const RoutineContext &ctx) { // Load auto nNPC = getInt(args, 0); - auto oidNPC = getObject(args, 1, ctx); - - // Transform + auto oidNPC = getObjectOrNull(args, 1, ctx); // Execute - throw RoutineNotImplementedException("SetAvailableNPCId"); + const RosterIdentity identity {RosterKind::Npc, nNPC}; + if (!oidNPC) { + if (args[1].objectId == kObjectInvalid && + ctx.game.party().isRosterAvailable(identity)) { + ctx.game.party().clearRosterCreature(identity); + } + } else { + ctx.game.party().bindRosterCreature(identity, checkCreature(oidNPC)); + } + return Variable::ofNull(); } static Variable GetScriptParameter(const std::vector &args, const RoutineContext &ctx) { @@ -6883,10 +6897,11 @@ static Variable AddAvailablePUPByTemplate(const std::vector &args, con auto nPUP = getInt(args, 0); auto sTemplate = getString(args, 1); - // Transform + auto tmplt = boost::to_lower_copy(sTemplate); // Execute - throw RoutineNotImplementedException("AddAvailablePUPByTemplate"); + return Variable::ofInt(ctx.game.party().addAvailableRosterRecord( + {RosterKind::Puppet, nPUP}, tmplt)); } static Variable AddAvailablePUPByObject(const std::vector &args, const RoutineContext &ctx) { @@ -6894,10 +6909,11 @@ static Variable AddAvailablePUPByObject(const std::vector &args, const auto nPUP = getInt(args, 0); auto oPuppet = getObject(args, 1, ctx); - // Transform + auto creature = checkCreature(oPuppet); // Execute - throw RoutineNotImplementedException("AddAvailablePUPByObject"); + return Variable::ofInt(ctx.game.party().addAvailableRosterRecord( + {RosterKind::Puppet, nPUP}, creature)); } static Variable AssignPUP(const std::vector &args, const RoutineContext &ctx) { @@ -6905,10 +6921,8 @@ static Variable AssignPUP(const std::vector &args, const RoutineContex auto nPUP = getInt(args, 0); auto nNPC = getInt(args, 1); - // Transform - // Execute - throw RoutineNotImplementedException("AssignPUP"); + return Variable::ofInt(ctx.game.party().assignPuppet(nPUP, nNPC)); } static Variable SpawnAvailablePUP(const std::vector &args, const RoutineContext &ctx) { @@ -6916,10 +6930,25 @@ static Variable SpawnAvailablePUP(const std::vector &args, const Routi auto nPUP = getInt(args, 0); auto lLocation = getLocationArgument(args, 1); - // Transform - // Execute - throw RoutineNotImplementedException("SpawnAvailablePUP"); + auto puppet = ctx.game.party().getAvailablePuppet(nPUP, true); + auto module = ctx.game.module(); + auto area = module ? module->area() : nullptr; + if (!puppet || !area) { + return Variable::ofObject(kObjectInvalid); + } + puppet->setPuppet(true); + puppet->setPosition(lLocation->position()); + puppet->setFacing(lLocation->facing()); + area->landObject(*puppet); + const bool alreadyResident = std::find( + area->objects().begin(), area->objects().end(), puppet) != + area->objects().end(); + if (!alreadyResident) { + area->add(puppet); + puppet->runSpawnScript(); + } + return Variable::ofObject(puppet->id()); } static Variable AddPartyPuppet(const std::vector &args, const RoutineContext &ctx) { @@ -6927,30 +6956,35 @@ static Variable AddPartyPuppet(const std::vector &args, const RoutineC auto nPUP = getInt(args, 0); auto oidCreature = getObject(args, 1, ctx); - // Transform + auto creature = checkCreature(oidCreature); // Execute - throw RoutineNotImplementedException("AddPartyPuppet"); + return Variable::ofInt(ctx.game.party().addPuppet(nPUP, creature)); } static Variable GetPUPOwner(const std::vector &args, const RoutineContext &ctx) { // Load auto oPUP = getObjectOrCaller(args, 0, ctx); - // Transform - // Execute - throw RoutineNotImplementedException("GetPUPOwner"); + auto creature = dyn_cast(oPUP); + auto identity = creature + ? ctx.game.party().rosterIdentity(*creature) + : std::nullopt; + if (!identity || identity->kind != RosterKind::Puppet) { + return Variable::ofObject(kObjectInvalid); + } + auto owner = ctx.game.party().puppetOwner(identity->slot); + return Variable::ofObject(getObjectIdOrInvalid(owner)); } static Variable GetIsPuppet(const std::vector &args, const RoutineContext &ctx) { // Load auto oPUP = getObjectOrCaller(args, 0, ctx); - // Transform - // Execute - throw RoutineNotImplementedException("GetIsPuppet"); + auto creature = dyn_cast(oPUP); + return Variable::ofInt(creature && creature->isPuppet()); } static Variable GetIsPartyLeader(const std::vector &args, const RoutineContext &ctx) { @@ -6974,9 +7008,35 @@ static Variable RemoveNPCFromPartyToBase(const std::vector &args, cons auto nNPC = getInt(args, 0); // Transform + if (nNPC < 0 || nNPC >= static_cast(Party::kK2NpcCount)) { + return Variable::ofInt(0); + } // Execute - throw RoutineNotImplementedException("RemoveNPCFromPartyToBase"); + Party &party = ctx.game.party(); + const RosterIdentity identity {RosterKind::Npc, nNPC}; + auto creature = party.rosterCreature(identity); + if (!creature) { + return Variable::ofInt(0); + } + const bool wasControlled = party.controlledNpc() == nNPC; + auto canonicalPlayer = party.actualPlayer(); + if (wasControlled && canonicalPlayer.get() == creature.get()) { + // A malformed controlled-NPC topology has no canonical PC to resume. + // Fail closed instead of retiring the session's only controlled actor. + return Variable::ofInt(0); + } + + if (party.isRosterAvailable(identity) && + !party.removeMemberToBase(nNPC)) { + return Variable::ofInt(0); + } + const bool killed = ctx.game.killRosterCreature(identity); + if (killed && wasControlled && canonicalPlayer && + !party.isMember(*canonicalPlayer)) { + party.addMember(kNpcPlayer, canonicalPlayer); + } + return Variable::ofInt(static_cast(killed)); } static Variable CreatureFlourishWeapon(const std::vector &args, const RoutineContext &ctx) { @@ -7215,10 +7275,12 @@ static Variable SaveNPCByObject(const std::vector &args, const Routine auto nNPC = getInt(args, 0); auto oidCharacter = getObject(args, 1, ctx); - // Transform + auto creature = checkCreature(oidCharacter); // Execute - throw RoutineNotImplementedException("SaveNPCByObject"); + creature->clearAllActions(); + ctx.game.saveRosterState({RosterKind::Npc, nNPC}, *creature); + return Variable::ofNull(); } static Variable SavePUPByObject(const std::vector &args, const RoutineContext &ctx) { @@ -7226,10 +7288,12 @@ static Variable SavePUPByObject(const std::vector &args, const Routine auto nPUP = getInt(args, 0); auto oidPuppet = getObject(args, 1, ctx); - // Transform + auto creature = checkCreature(oidPuppet); // Execute - throw RoutineNotImplementedException("SavePUPByObject"); + creature->clearAllActions(); + ctx.game.saveRosterState({RosterKind::Puppet, nPUP}, *creature); + return Variable::ofNull(); } static Variable GetIsPlayerMadeCharacter(const std::vector &args, const RoutineContext &ctx) { @@ -7244,7 +7308,39 @@ static Variable GetIsPlayerMadeCharacter(const std::vector &args, cons static Variable RebuildPartyTable(const std::vector &args, const RoutineContext &ctx) { // Execute - throw RoutineNotImplementedException("RebuildPartyTable"); + auto module = ctx.game.module(); + auto area = module ? module->area() : nullptr; + if (!area) return Variable::ofNull(); + + // Retail K2 deliberately authors this association in routine 876. It is + // the one place where these content Tags mean "bind this object to this + // PartyTable slot"; they are not general engine identities. + static const std::array kNpcTags { + "atton", "baodur", "mand", "g0t0", "handmaiden", "hk47", + "kreia", "mira", "t3m4", "visasmarr", "hanharr", "disciple"}; + auto findCreature = [&area](const std::string &tag) { + for (const auto &object : area->objects()) { + if (object->type() == ObjectType::Creature && + boost::iequals(object->tag(), tag)) { + return std::static_pointer_cast(object); + } + } + return std::shared_ptr {}; + }; + auto &party = ctx.game.party(); + for (size_t slot = 0; slot < kNpcTags.size(); ++slot) { + if (!party.isMemberAvailable(static_cast(slot))) continue; + if (auto creature = findCreature(kNpcTags[slot])) { + party.bindRosterCreature( + {RosterKind::Npc, static_cast(slot)}, creature); + } + } + if (party.isRosterAvailable({RosterKind::Puppet, 0})) { + if (auto remote = findCreature("remote")) { + party.bindRosterCreature({RosterKind::Puppet, 0}, remote); + } + } + return Variable::ofNull(); } void Routines::registerMainKotorRoutines() { diff --git a/src/libs/game/script/routines.cpp b/src/libs/game/script/routines.cpp index 07a7e6ce7..424aa63f0 100644 --- a/src/libs/game/script/routines.cpp +++ b/src/libs/game/script/routines.cpp @@ -18,8 +18,11 @@ #include "reone/game/script/routines.h" #include "reone/game/di/services.h" +#include "reone/game/effect.h" +#include "reone/game/game.h" #include "reone/game/script/routine/context.h" #include "reone/game/types.h" +#include "reone/script/executioncontext.h" #include "reone/script/variable.h" using namespace reone::resource; @@ -89,14 +92,31 @@ void Routines::insert( break; } + const bool constructsEffect = + retType == VariableType::Effect && name.rfind("Effect", 0) == 0; _routines[index] = Routine( std::move(name), retType, std::move(defRetValue), std::move(argTypes), - [this, fn](auto &args, auto &execution) { + [this, fn, retType, constructsEffect](auto &args, auto &execution) { RoutineContext ctx(*_game, *_services, execution); - return fn(args, std::move(ctx)); + auto result = fn(args, std::move(ctx)); + // Retail stamps VM-created CGameEffect values with OBJECT_SELF. + // Keep that save-facing creator independently of executable + // subclass behavior so delayed continuations can serialize it. + if (constructsEffect) { + auto effect = std::dynamic_pointer_cast(result.engineType); + if (effect) { + effect->captureSaveFacingScriptArguments(args, *_game); + } + auto caller = execution.findArg(ArgKind::Caller); + if (effect && caller) { + effect->setSaveFacingCreator( + _game->getObjectById(caller->objectId)); + } + } + return result; }); } diff --git a/src/libs/game/script/savedsituation.cpp b/src/libs/game/script/savedsituation.cpp index af8ed5429..c53387935 100644 --- a/src/libs/game/script/savedsituation.cpp +++ b/src/libs/game/script/savedsituation.cpp @@ -72,16 +72,23 @@ ByteBuffer withNcsHeader(const ByteBuffer &code) { return result; } +uint32_t runtimeObjectId(const SavedObjectReference &reference) { + if (reference.isInvalid()) return script::kObjectInvalid; + if (auto object = reference.boundObject()) return object->id(); + return reference.isSerializedIdentity() + ? script::kObjectInvalid + : reference.id; +} + script::Variable objectVariable(const SavedObjectReference &reference) { - uint32_t id = reference.isInvalid() ? script::kObjectInvalid : reference.id; - return script::Variable::ofObject(id); + return script::Variable::ofObject(runtimeObjectId(reference)); } std::shared_ptr eventValue(const SavedScriptEvent &saved) { - std::vector objects; + std::vector> objects; objects.reserve(saved.objects.size()); for (const auto &reference : saved.objects) { - objects.push_back(reference.isInvalid() ? script::kObjectInvalid : reference.id); + objects.push_back(reference.boundObject()); } return std::make_shared( saved.type, @@ -91,6 +98,28 @@ std::shared_ptr eventValue(const SavedScriptEvent &saved) { std::move(objects)); } +EffectInstance runtimeEffectInstance(const EffectInstance &saved) { + EffectInstance result = saved; + if (!result.hasSerializedObjectReferences()) return result; + + if (result.creatorId != kSavedEffectInvalidObjectId && + result.creatorId != kSavedRuntimeInvalidObjectId) { + auto creator = result.boundCreator(); + result.creatorId = creator ? creator->id() : kSavedRuntimeInvalidObjectId; + } + for (size_t index = 0; index < result.objectParameters.size(); ++index) { + uint32_t &id = result.objectParameters[index]; + if (id == kSavedEffectInvalidObjectId || + id == kSavedRuntimeInvalidObjectId) { + continue; + } + auto object = result.boundObjectParameter(index); + id = object ? object->id() : kSavedRuntimeInvalidObjectId; + } + result.serializedReferenceContext.reset(); + return result; +} + bool convertStackValue( const SavedVmStackValue &saved, script::Variable &result, @@ -122,7 +151,8 @@ bool convertStackValue( break; case SavedVmStackType::Effect: if (auto value = std::get_if(&saved.payload)) { - result = script::Variable::ofEffect(std::make_shared(*value)); + result = script::Variable::ofEffect( + std::make_shared(runtimeEffectInstance(*value))); if (value->hasStableId()) { effectIds.push_back(value->id); } @@ -153,7 +183,7 @@ bool convertStackValue( static_cast(value->type), value->id, value->multiClass, - value->item.id, + runtimeObjectId(value->item), value->itemPropertyIndex, value->casterLevel, value->metaType)); diff --git a/src/libs/resource/director.cpp b/src/libs/resource/director.cpp index 001f931eb..74685d51c 100644 --- a/src/libs/resource/director.cpp +++ b/src/libs/resource/director.cpp @@ -22,10 +22,15 @@ #include "reone/graphics/types.h" #include "reone/resource/di/services.h" #include "reone/resource/exception/notfound.h" +#include "reone/resource/format/gffreader.h" +#include "reone/resource/format/lytreader.h" #include "reone/resource/modulediscovery.h" #include "reone/resource/modulemount.h" #include "reone/resource/modulepolicy.h" #include "reone/resource/mounttransaction.h" +#include "reone/resource/parser/gff/are.h" +#include "reone/resource/parser/gff/git.h" +#include "reone/resource/parser/gff/ifo.h" #include "reone/resource/provider/2das.h" #include "reone/resource/provider/dialogs.h" #include "reone/resource/provider/gffs.h" @@ -36,6 +41,7 @@ #include "reone/script/di/services.h" #include "reone/system/fileutil.h" #include "reone/system/logutil.h" +#include "reone/system/stream/memoryinput.h" #include @@ -102,15 +108,8 @@ void ResourceDirector::init() { * to rebuild the installation or to leave the save that is loaded. */ void ResourceDirector::onModuleLoad(const std::string &name) { - _dialogs.clear(); - _paths.clear(); - _scripts.clear(); - _lips.clear(); - _gffs.clear(); - _resources.clearOwner(ResourceOwner::ActiveModule); - _resources.clearOwner(ResourceOwner::ActiveModuleState); - - loadModuleResources(name); + commitModuleLoad(planModuleLoad( + name, _saveSession ? _saveSession->workingState() : nullptr)); } /** @@ -138,6 +137,116 @@ void ResourceDirector::commitGameLoad(std::unique_ptr candidat commitSaveSession(std::move(candidate)); } +namespace { + +std::shared_ptr decodeModuleGff( + std::optional resource, + const std::string &description) { + if (!resource) { + throw ResourceNotFoundException(description + " not found"); + } + MemoryInputStream stream(resource->data); + GffReader reader(stream); + reader.load(); + return reader.root(); +} + +ModuleLoadPlan temporaryModulePlan(ModuleLoadPlan plan) { + for (auto &family : plan.families) { + for (auto &attempt : family.attempts) { + attempt.metadata.owner = ResourceOwner::TemporaryDiscovery; + } + } + plan.activeState.owner = ResourceOwner::TemporaryDiscovery; + plan.activeState.encapsulatedArchive.owner = ResourceOwner::TemporaryDiscovery; + plan.activeState.resourceImage.owner = ResourceOwner::TemporaryDiscovery; + return plan; +} + +} // namespace + +std::unique_ptr ResourceDirector::prepareModuleLoad( + const std::string &name, + std::shared_ptr workingState) { + auto candidate = planModuleLoad(name, std::move(workingState)); + + // The outgoing module stays mounted while the candidate is inspected. + // Candidate sources use their real buckets but a temporary owner; filtered + // lookup therefore sees globals/overrides plus the candidate, never the + // outgoing module's generic module.ifo. + ResourceMountTransaction transaction(_resources); + auto report = mountModuleLoad(*candidate, /*temporary=*/true); + if (report.outcome == ModuleLoadOutcome::Failed) { + throw ResourceNotFoundException( + "Module resources could not be prepared: " + candidate->_moduleName); + } + + static const std::set kOutgoingModuleOwners { + ResourceOwner::ActiveModule, + ResourceOwner::ActiveModuleState}; + auto findCandidate = [this](const ResourceId &id) { + return _resources.findExcludingOwners(id, kOutgoingModuleOwners); + }; + + candidate->_moduleIfo = decodeModuleGff( + findCandidate(ResourceId("module", ResType::Ifo)), + "Module IFO"); + auto ifo = generated::parseIFO(*candidate->_moduleIfo); + if (ifo.Mod_Entry_Area.empty()) { + throw ValidationException("Mod_Entry_Area must not be empty"); + } + + candidate->_areaAre = decodeModuleGff( + findCandidate(ResourceId(ifo.Mod_Entry_Area, ResType::Are)), + "Area ARE"); + candidate->_areaGit = decodeModuleGff( + findCandidate(ResourceId(ifo.Mod_Entry_Area, ResType::Git)), + "Area GIT"); + (void)generated::parseARE(*candidate->_areaAre); + (void)generated::parseGIT(*candidate->_areaGit); + + auto layout = findCandidate(ResourceId(ifo.Mod_Entry_Area, ResType::Lyt)); + if (!layout) { + throw ResourceNotFoundException( + "Area LYT not found: " + ifo.Mod_Entry_Area); + } + MemoryInputStream layoutStream(layout->data); + LytReader layoutReader; + layoutReader.load(layoutStream); + + candidate->_structurallyValidated = true; + return candidate; +} + +void ResourceDirector::commitModuleLoad( + std::unique_ptr candidate) { + if (!candidate) { + throw ValidationException("Prepared module load is missing"); + } + + _dialogs.clear(); + _paths.clear(); + _scripts.clear(); + _lips.clear(); + _gffs.clear(); + _resources.clearOwner(ResourceOwner::ActiveModule); + _resources.clearOwner(ResourceOwner::ActiveModuleState); + + ResourceMountTransaction transaction(_resources); + auto report = mountModuleLoad(*candidate, /*temporary=*/false); + if (report.outcome != ModuleLoadOutcome::Failed) { + transaction.commit(); + } else if (candidate->_structurallyValidated) { + // Preparation already opened this exact plan. A failure now means the + // external resource changed across the commit boundary; the caller has + // already chosen replacement and must enter its terminal failure path. + throw ResourceNotFoundException( + "Prepared module resources changed before commit: " + + candidate->_moduleName); + } + logModuleMount(*candidate, report); +} + void ResourceDirector::commitSaveSession(std::unique_ptr candidate) { _resources.clearOwner(ResourceOwner::SaveSlot); _saveSession = std::move(candidate); @@ -595,10 +704,6 @@ void ResourceDirector::loadGlobalResources() { loadLiveResources(); } -void ResourceDirector::loadModuleResources(const std::string &name) { - loadModuleResourcesFromPolicy(name); -} - std::vector ResourceDirector::moduleSearchRoots() { return resource::moduleSearchRoots(_gameId, _gamePath, _odysseyRoots); } @@ -612,6 +717,7 @@ std::vector ResourceDirector::moduleSearchRoots() { * before the saved archive because the policy ranks it first. */ void ResourceDirector::addStagedModuleSources(const std::string &moduleRoot, + const std::shared_ptr &workingState, RuntimeModuleSourceIndex &index) { struct Probe { ResType type; @@ -623,10 +729,9 @@ void ResourceDirector::addStagedModuleSources(const std::string &moduleRoot, {ResType::Sav, ModuleArchiveFamily::SavedArchive, ".sav"}, }}; - if (!_saveSession) { + if (!workingState) { return; } - auto workingState = _saveSession->workingState(); for (const auto &probe : kProbes) { auto id = ResourceId(moduleRoot, probe.type); if (!workingState->contains(id)) { @@ -666,7 +771,9 @@ bool ResourceDirector::includeModuleInSave(const std::string &moduleRoot) { return *include != 0; } -void ResourceDirector::loadModuleResourcesFromPolicy(const std::string &name) { +std::unique_ptr ResourceDirector::planModuleLoad( + const std::string &name, + std::shared_ptr workingState) { auto discovered = discoverModuleSources(name, moduleSearchRoots()); if (discovered.nameRejection) { throw ResourceNotFoundException("Not a supported module name: " + name); @@ -680,7 +787,7 @@ void ResourceDirector::loadModuleResourcesFromPolicy(const std::string &name) { for (const auto &source : discovered.sources) { index.add(RuntimeModuleSource {source.candidate, source.path}); } - addStagedModuleSources(discovered.moduleRoot, index); + addStagedModuleSources(discovered.moduleRoot, workingState, index); ModulePolicyRequest request; request.game = _gameId; @@ -706,35 +813,29 @@ void ResourceDirector::loadModuleResourcesFromPolicy(const std::string &name) { request.savedMode = selection && selection->candidate.kind == ModulePrimaryKind::SavedArchive; - auto plan = planModuleLoad(request, inventory); - - // Mounting the module is one operation. Sources are mounted in phases and a - // later one can throw, which would otherwise leave the earlier phases in - // place: a module that is neither the old one nor the new one, still - // answering lookups. Whatever is mounted from here on goes away together if - // the load does not finish. - // - // This does not put the previous module back. Its sources were retired - // before this ran, and reinstating them would be a new load rather than an - // undo of this one; whether the caller can still use the module it had is - // its own question. - ResourceMountTransaction transaction(_resources); - - ModuleMountExecutor executor(_resources, index); - auto report = executor.run(plan); + auto plan = resource::planModuleLoad(request, inventory); + return std::unique_ptr(new PreparedModuleLoad( + discovered.moduleRoot, std::move(index), std::move(plan))); +} - if (report.outcome != ModuleLoadOutcome::Failed) { - transaction.commit(); - } +ModuleMountReport ResourceDirector::mountModuleLoad( + const PreparedModuleLoad &candidate, + bool temporary) { + auto plan = temporary + ? temporaryModulePlan(candidate._plan) + : candidate._plan; + ModuleMountExecutor executor(_resources, candidate._sources); + return executor.run(plan); +} - if (!plan.primary) { - warn("No primary source found for module '" + discovered.moduleRoot + "'"); +void ResourceDirector::logModuleMount( + const PreparedModuleLoad &candidate, + const ModuleMountReport &report) const { + if (!candidate._plan.primary) { + warn("No primary source found for module '" + candidate._moduleName + "'"); } if (report.requiredFailure) { - warn("Module '" + discovered.moduleRoot + "' had a required source fail to mount"); - } - for (const auto &file : discovered.rejected) { - debug("Ignoring unsupported module file: " + file.filename); + warn("Module '" + candidate._moduleName + "' had a required source fail to mount"); } // Which source supplied a resource is not recoverable from the payload, so // record the sequence that was actually mounted. diff --git a/src/libs/resource/extractresources.cpp b/src/libs/resource/extractresources.cpp index 0568b3e63..77f785087 100644 --- a/src/libs/resource/extractresources.cpp +++ b/src/libs/resource/extractresources.cpp @@ -190,7 +190,16 @@ Resource ExtractResources::get(const ResourceId &id) { } std::optional ExtractResources::find(const ResourceId &id) { + return findExcludingOwners(id, {}); +} + +std::optional ExtractResources::findExcludingOwners( + const ResourceId &id, + const std::set &excludedOwners) { for (auto &source : _sources) { + if (excludedOwners.count(source.owner) != 0) { + continue; + } auto data = source.find(id); if (data) { return Resource {std::move(*data)}; diff --git a/src/libs/resource/replacementresources.cpp b/src/libs/resource/replacementresources.cpp index 1926bad92..da22f8cb7 100644 --- a/src/libs/resource/replacementresources.cpp +++ b/src/libs/resource/replacementresources.cpp @@ -80,6 +80,15 @@ std::optional ReplacementResources::find(const ResourceId &id) { return replacement ? replacement : _backend->find(id); } +std::optional ReplacementResources::findExcludingOwners( + const ResourceId &id, + const std::set &excludedOwners) { + auto replacement = _replacements.findResourceReplacement(id); + return replacement + ? replacement + : _backend->findExcludingOwners(id, excludedOwners); +} + } // namespace resource } // namespace reone diff --git a/src/libs/resource/resources.cpp b/src/libs/resource/resources.cpp index 7879703f6..d9733387e 100644 --- a/src/libs/resource/resources.cpp +++ b/src/libs/resource/resources.cpp @@ -79,7 +79,16 @@ Resource Resources::get(const ResourceId &id) { } std::optional Resources::find(const ResourceId &id) { + return findExcludingOwners(id, {}); +} + +std::optional Resources::findExcludingOwners( + const ResourceId &id, + const std::set &excludedOwners) { for (auto &container : _containers) { + if (excludedOwners.count(container.owner) != 0) { + continue; + } auto data = container.provider->findResourceData(id); if (data) { return Resource {*data}; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index af0dcc32b..26b3c77d9 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -85,6 +85,7 @@ set(TESTS_SOURCES ${TESTS_SOURCE_DIR}/game/partyroutines.cpp ${TESTS_SOURCE_DIR}/game/pathfinder.cpp ${TESTS_SOURCE_DIR}/game/reputes.cpp + ${TESTS_SOURCE_DIR}/game/rostermaterialization.cpp ${TESTS_SOURCE_DIR}/game/statussummary.cpp ${TESTS_SOURCE_DIR}/game/stringroutines.cpp ${TESTS_SOURCE_DIR}/game/timingepoch.cpp diff --git a/test/fixtures/game.h b/test/fixtures/game.h index f88e6369a..89f1cdc6a 100644 --- a/test/fixtures/game.h +++ b/test/fixtures/game.h @@ -46,13 +46,20 @@ namespace reone { namespace resource { class Gff; +struct SaveSlotDescriptor; +class SaveWorkingState; } +namespace scene { +class SceneNode; +} + namespace game { class Area; class Creature; +struct Pathfinder; class Door; class Game; class Item; @@ -62,6 +69,7 @@ class Conversation; class Object; class StaticCamera; class Trigger; +struct SerializedIdentityContext; struct SaveOrchestrationSeams; struct SaveResult; @@ -188,7 +196,13 @@ class TestGameModule : boost::noncopyable { static size_t objectRegistrySize(const Game &game); static size_t loadedModuleCount(const Game &game); static uint32_t nextObjectId(const Game &game); + static std::shared_ptr newItemAtRuntimeId(Game &game, uint32_t id); static uint64_t runtimeSessionGeneration(const Game &game); + static uint64_t savedGraphGeneration(const Game &game); + static void registerSavedModuleReferenceTarget( + Game &game, + const std::shared_ptr &module, + const SerializedIdentityContext &identityContext); static void bindConversation(Game &game, Conversation &conversation); static bool hasConversation(const Game &game); static void bindHUDSelection(Game &game, std::shared_ptr object); @@ -215,10 +229,24 @@ class TestGameModule : boost::noncopyable { resource::Gff &ifoGff, const std::shared_ptr &ptGff, const std::shared_ptr &pcGff); - static void deserializeAvailableNpcs(Game &game); + static void publishPartyRuntimeState( + Game &game, + resource::Gff &ifoGff, + const std::shared_ptr &ptGff, + const std::shared_ptr &pcGff, + const SerializedIdentityContext &identityContext); static void deserializeInventory(Game &game, resource::Gff &gff); static void deserializeCustomTokens(Game &game, const resource::Gff &gff); static void deserializeGlobalVariables(Game &game, resource::Gff &gff); + static void inspectPreparedSaveLoad( + Game &game, + const resource::SaveSlotDescriptor &slot, + int &context, + std::string &playerTag, + bool &playerHasObjectId, + std::string &startWaypoint, + uint32_t &pauseDay, + uint32_t &pauseTime); static void replaceJournal(Game &game, const resource::Gff &gff); static void replaceInventory(Game &game, resource::Gff &gff); static void configureModuleSnapshot( @@ -235,6 +263,12 @@ class TestGameModule : boost::noncopyable { static void dispatchSnapshotEvents(Module &module); static void clearSnapshotDelayed(Object &object); static size_t delayedActionCount(const Object &object); + static void setAreaRuntimePath(Creature &creature, Pathfinder &pathfinder); + static bool hasAreaRuntimePath(const Creature &creature); + static size_t seenObjectCount(const Creature &creature); + static size_t heardObjectCount(const Creature &creature); + static void setAreaRuntimeSceneNode( + Object &object, std::shared_ptr sceneNode); static void initSnapshotLocalServices(Game &game); static void setSnapshotWorldTime( Game &game, uint32_t day, uint32_t time, uint8_t minutesPerHour); @@ -242,8 +276,14 @@ class TestGameModule : boost::noncopyable { static void setSnapshotMinutesPerHour(Game &game, uint8_t minutesPerHour); static void advanceWorldTime(Game &game, float dt); static void prepareWorldTimeFromIfo(Game &game, const resource::Gff &ifo); + static void restoreAutosaveWorldTime( + Game &game, + const resource::Gff &moduleIfo, + uint32_t pauseDay, + uint32_t pauseTime); static void deserializeSnapshotRuntimeState( - Object &object, const resource::Gff &gff); + Object &object, const resource::Gff &gff, + const SerializedIdentityContext &identityContext); static void setSnapshotObjectId(Object &object, uint32_t objectId); static void setSnapshotEquipment( Creature &creature, int slot, std::shared_ptr item); @@ -260,7 +300,8 @@ class TestGameModule : boost::noncopyable { static void configureSaveOrchestration( Game &game, SaveOrchestrationSeams seams); static void processPendingSave(Game &game); - static bool storeCurrentModuleForTransition(Game &game); + static std::shared_ptr + prepareCurrentModuleWorkingState(Game &game); static void setSnapshotModuleName(Game &game, std::string name); static bool hasPendingSave(const Game &game); static void setRuntimeSessionPlayable(Game &game, bool playable); @@ -311,6 +352,7 @@ class TestGameModule : boost::noncopyable { return *_services; } + MockClasses &classes() { return *_classes; } MockSpells &spells() { return *_spells; } MockPortraits &portraits() { return *_portraits; } diff --git a/test/fixtures/resource.h b/test/fixtures/resource.h index 8d762e8f8..47b22cf0d 100644 --- a/test/fixtures/resource.h +++ b/test/fixtures/resource.h @@ -47,6 +47,21 @@ namespace reone { namespace resource { +class PreparedModuleLoadTestFactory { +public: + static std::unique_ptr validated( + std::string moduleName, + std::shared_ptr moduleIfo, + std::shared_ptr areaAre, + std::shared_ptr areaGit) { + return std::unique_ptr(new PreparedModuleLoad( + std::move(moduleName), + std::move(moduleIfo), + std::move(areaAre), + std::move(areaGit))); + } +}; + class MockGffs : public IGffs, boost::noncopyable { public: MOCK_METHOD(void, clear, (), (override)); @@ -69,6 +84,10 @@ class MockResources : public IResources, boost::noncopyable { MOCK_METHOD(Resource, get, (const ResourceId &id), (override)); MOCK_METHOD(std::optional, find, (const ResourceId &id), (override)); + MOCK_METHOD(std::optional, findExcludingOwners, + (const ResourceId &id, + const std::set &excludedOwners), + (override)); }; class MockResourceReplacements : public IResourceReplacements, boost::noncopyable { @@ -194,6 +213,12 @@ class MockResourceDirector : public IResourceDirector, boost::noncopyable { (const SaveSlotDescriptor &slot), (override)); MOCK_METHOD(void, commitGameLoad, (std::unique_ptr candidate), (override)); + MOCK_METHOD(std::unique_ptr, prepareModuleLoad, + (const std::string &name, + std::shared_ptr workingState), + (override)); + MOCK_METHOD(void, commitModuleLoad, + (std::unique_ptr candidate), (override)); MOCK_METHOD(std::optional, findSaveMetadata, (const ResourceId &id), (override)); MOCK_METHOD(std::optional, findSaveWorking, (const ResourceId &id), (override)); MOCK_METHOD(std::unordered_set, saveWorkingResourceIds, (), (const, override)); diff --git a/test/game/area.cpp b/test/game/area.cpp index ce2422364..864d1a0d2 100644 --- a/test/game/area.cpp +++ b/test/game/area.cpp @@ -23,6 +23,7 @@ #include "reone/game/game.h" #include "reone/game/object.h" #include "reone/game/object/area.h" +#include "reone/system/exception/validation.h" using namespace reone; using namespace reone::game; @@ -69,4 +70,30 @@ TEST(Area, get_object_by_tag_should_partition_by_is_dead) { EXPECT_EQ(dead3, area->getObjectByTag(tag, 3)); } +TEST(Area, failed_room_attachment_rolls_back_area_ownership) { + TestEngine &engine = testEngine(); + engine.init(); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + NiceMock sceneGraph; + ON_CALL(engine.sceneModule().graphs(), get(_)) + .WillByDefault(ReturnRef(sceneGraph)); + EXPECT_CALL(sceneGraph, testElevation(_, _)) + .WillOnce(Throw(ValidationException("injected room attachment failure"))); + + auto area = game.newArea(); + auto item = game.newItem(); + item->setTag("attachment_failure"); + const size_t registrySize = engine.gameModule().objectRegistrySize(game); + + EXPECT_THROW(area->add(item), ValidationException); + + EXPECT_TRUE(item->isRuntimeLive()); + EXPECT_EQ(item, game.getObjectById(item->id())); + EXPECT_EQ(registrySize, engine.gameModule().objectRegistrySize(game)); + EXPECT_TRUE(area->getObjectsByType(ObjectType::Item).empty()); + EXPECT_FALSE(area->getObjectByTag("attachment_failure", 0)); + EXPECT_EQ(nullptr, item->room()); +} + } // namespace diff --git a/test/game/effect.cpp b/test/game/effect.cpp index 23f2926d5..ec4a01458 100644 --- a/test/game/effect.cpp +++ b/test/game/effect.cpp @@ -16,6 +16,7 @@ #include "reone/game/game.h" #include "reone/game/object.h" #include "reone/resource/gff.h" +#include "reone/script/variable.h" using namespace reone; using namespace reone::game; @@ -78,6 +79,14 @@ std::shared_ptr richSavedEffect(uint16_t subType = 0x11, bool skipOnLoad = .build(); } +EffectInstance parsedSavedEffect( + uint16_t subType = 0x11, + bool skipOnLoad = false) { + return EffectInstance::fromGff( + *richSavedEffect(subType, skipOnLoad), + SerializedIdentityContext::moduleGraph("test-module")); +} + class CountingEffect : public Effect { public: CountingEffect() : Effect(EffectType::Haste) {} @@ -97,7 +106,7 @@ class EffectTestObject : public Object { }; TEST(SavedEffect, should_preserve_the_complete_observed_payload) { - EffectInstance effect = EffectInstance::fromGff(*richSavedEffect()); + EffectInstance effect = parsedSavedEffect(); EXPECT_EQ(effect.id, 0x100000002ULL); EXPECT_EQ(effect.retailType, 68); @@ -123,19 +132,19 @@ TEST(SavedEffect, should_preserve_the_complete_observed_payload) { } TEST(SavedEffect, should_decode_all_directly_proven_duration_types_from_subtype) { - EXPECT_EQ(EffectInstance::fromGff(*richSavedEffect(0)).durationType(), DurationType::Instant); - EXPECT_EQ(EffectInstance::fromGff(*richSavedEffect(1)).durationType(), DurationType::Temporary); - EXPECT_EQ(EffectInstance::fromGff(*richSavedEffect(2)).durationType(), DurationType::Permanent); - EXPECT_EQ(EffectInstance::fromGff(*richSavedEffect(3)).durationType(), DurationType::Equipped); - EXPECT_EQ(EffectInstance::fromGff(*richSavedEffect(4)).durationType(), DurationType::Innate); - EXPECT_EQ(EffectInstance::fromGff(*richSavedEffect(7)).durationType(), DurationType::Invalid); + EXPECT_EQ(parsedSavedEffect(0).durationType(), DurationType::Instant); + EXPECT_EQ(parsedSavedEffect(1).durationType(), DurationType::Temporary); + EXPECT_EQ(parsedSavedEffect(2).durationType(), DurationType::Permanent); + EXPECT_EQ(parsedSavedEffect(3).durationType(), DurationType::Equipped); + EXPECT_EQ(parsedSavedEffect(4).durationType(), DurationType::Innate); + EXPECT_EQ(parsedSavedEffect(7).durationType(), DurationType::Invalid); } TEST(SavedEffect, should_follow_direct_skip_and_equipped_load_policy) { - EXPECT_FALSE(EffectInstance::fromGff(*richSavedEffect(1)).skipOnLoad); - EXPECT_TRUE(EffectInstance::fromGff(*richSavedEffect(1)).shouldRestoreOnLoad()); - EXPECT_FALSE(EffectInstance::fromGff(*richSavedEffect(3)).shouldRestoreOnLoad()); - EXPECT_FALSE(EffectInstance::fromGff(*richSavedEffect(1, true)).shouldRestoreOnLoad()); + EXPECT_FALSE(parsedSavedEffect(1).skipOnLoad); + EXPECT_TRUE(parsedSavedEffect(1).shouldRestoreOnLoad()); + EXPECT_FALSE(parsedSavedEffect(3).shouldRestoreOnLoad()); + EXPECT_FALSE(parsedSavedEffect(1, true).shouldRestoreOnLoad()); } TEST(EffectIdNamespace, should_import_authoritative_cursor_and_skip_imported_ids) { @@ -173,7 +182,7 @@ TEST(EffectIdNamespace, should_reset_all_session_identity) { EXPECT_EQ(ids.allocate(), 1); } -TEST(SavedEffect, should_bind_creator_only_through_the_current_runtime_registry) { +TEST(SavedEffect, should_not_rebind_serialized_objects_across_saved_graphs) { TestEngine &engine = testEngine(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); @@ -182,28 +191,61 @@ TEST(SavedEffect, should_bind_creator_only_through_the_current_runtime_registry) .WillByDefault(ReturnRef(sceneGraph)); auto creator = game.newCreature(); - EffectInstance effect = EffectInstance::fromGff(*richSavedEffect()); + EffectInstance effect = parsedSavedEffect(); effect.creatorId = creator->id(); + effect.objectParameters.fill(kSavedEffectInvalidObjectId); + game.registerSavedObjectIdentity( + effect.creatorId, + creator, + SerializedIdentityContext::moduleGraph("test-module")); EXPECT_TRUE(game.bindEffectCreator(effect)); EXPECT_EQ(effect.boundCreator(), creator); - game.retireRuntimeSession(); + game.retireActiveModuleRuntime(); EXPECT_FALSE(game.bindEffectCreator(effect)); EXPECT_FALSE(effect.boundCreator()); auto replacement = game.newCreature(); - ASSERT_EQ(replacement->id(), effect.creatorId); - EXPECT_TRUE(game.bindEffectCreator(effect)); - EXPECT_EQ(effect.boundCreator(), replacement); + game.registerSavedObjectIdentity( + effect.creatorId, + replacement, + SerializedIdentityContext::moduleGraph("test-module")); + EXPECT_FALSE(game.bindEffectCreator(effect)); + EXPECT_FALSE(effect.boundCreator()); + + auto replacementEffect = parsedSavedEffect(); + replacementEffect.creatorId = effect.creatorId; + replacementEffect.objectParameters.fill(kSavedEffectInvalidObjectId); + EXPECT_TRUE(game.bindEffectCreator(replacementEffect)); + EXPECT_EQ(replacementEffect.boundCreator(), replacement); +} + +TEST(SavedEffect, linked_vm_value_matches_retails_flat_inert_encoding) { + TestEngine &engine = testEngine(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + auto linked = std::make_shared(EffectType::LinkEffects); + linked->captureSaveFacingScriptArguments( + {script::Variable::ofEffect( + std::make_shared(EffectType::Haste)), + script::Variable::ofEffect( + std::make_shared(EffectType::Slow))}, + game); + + const auto saved = linked->saveFacingInstance(); + + EXPECT_EQ(saved.retailType, static_cast(EffectType::LinkEffects)); + EXPECT_TRUE(saved.integerParameters.empty()); + EXPECT_EQ(saved.creatorId, kSavedEffectInvalidObjectId); } TEST(EffectInstance, should_keep_unsupported_saved_effects_in_the_runtime_collection) { TestEngine &engine = testEngine(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); - auto object = std::make_shared(2, game, engine.services()); - EffectInstance effect = EffectInstance::fromGff(*richSavedEffect()); + auto object = game.newObject(game, engine.services()); + EffectInstance effect = parsedSavedEffect(); effect.id = 41; EXPECT_TRUE(object->restoreEffect(std::move(effect))); @@ -216,11 +258,11 @@ TEST(EffectInstance, should_remove_every_member_of_a_shared_id_group) { TestEngine &engine = testEngine(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); - auto object = std::make_shared(2, game, engine.services()); - EffectInstance left = EffectInstance::fromGff(*richSavedEffect(2)); - EffectInstance right = EffectInstance::fromGff(*richSavedEffect(2)); + auto object = game.newObject(game, engine.services()); + EffectInstance left = parsedSavedEffect(2); + EffectInstance right = parsedSavedEffect(2); left.id = right.id = 77; - EffectInstance unrelated = EffectInstance::fromGff(*richSavedEffect(2)); + EffectInstance unrelated = parsedSavedEffect(2); unrelated.id = 78; ASSERT_TRUE(object->restoreEffect(std::move(left))); ASSERT_TRUE(object->restoreEffect(std::move(right))); @@ -236,7 +278,7 @@ TEST(EffectInstance, should_preserve_ordinary_runtime_effect_application) { TestEngine &engine = testEngine(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); - auto object = std::make_shared(2, game, engine.services()); + auto object = game.newObject(game, engine.services()); auto effect = std::make_shared(); object->applyEffect(effect, DurationType::Permanent); @@ -251,7 +293,7 @@ TEST(EffectInstance, should_preserve_ordinary_instant_effect_application) { TestEngine &engine = testEngine(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); - auto object = std::make_shared(2, game, engine.services()); + auto object = game.newObject(game, engine.services()); auto effect = std::make_shared(); object->applyEffect(effect, DurationType::Instant); @@ -264,7 +306,7 @@ TEST(EffectInstance, should_expire_temporary_runtime_effects) { TestEngine &engine = testEngine(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); - auto object = std::make_shared(2, game, engine.services()); + auto object = game.newObject(game, engine.services()); auto effect = std::make_shared(); object->applyEffect(effect, DurationType::Temporary, 0.5f); @@ -280,10 +322,10 @@ TEST(EffectInstance, should_not_restore_skipped_or_equipped_records) { TestEngine &engine = testEngine(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); - auto object = std::make_shared(2, game, engine.services()); + auto object = game.newObject(game, engine.services()); - EXPECT_FALSE(object->restoreEffect(EffectInstance::fromGff(*richSavedEffect(1, true)))); - EXPECT_FALSE(object->restoreEffect(EffectInstance::fromGff(*richSavedEffect(3)))); + EXPECT_FALSE(object->restoreEffect(parsedSavedEffect(1, true))); + EXPECT_FALSE(object->restoreEffect(parsedSavedEffect(3))); EXPECT_TRUE(object->effects().empty()); } diff --git a/test/game/entrylifecycle.cpp b/test/game/entrylifecycle.cpp index 6b434de50..e51530cec 100644 --- a/test/game/entrylifecycle.cpp +++ b/test/game/entrylifecycle.cpp @@ -15,16 +15,23 @@ #include "../fixtures/scene.h" #include "reone/game/game.h" +#include "reone/game/action/attackobject.h" +#include "reone/game/action/docommand.h" +#include "reone/game/action/wait.h" #include "reone/game/object/area.h" #include "reone/game/types.h" #include "reone/game/object/creature.h" #include "reone/game/object/module.h" +#include "reone/game/object/trigger.h" #include "reone/game/party.h" +#include "reone/game/room.h" #include "reone/game/script/routines.h" #include "reone/resource/gff.h" #include "reone/resource/layout.h" +#include "reone/scene/collision.h" #include "reone/scene/node/camera.h" #include "reone/script/executioncontext.h" +#include "reone/script/executionstate.h" #include "reone/script/program.h" using namespace reone; @@ -40,7 +47,8 @@ constexpr char kOnLoadScript[] = "hook_onload"; constexpr char kOnEnterScript[] = "hook_onenter"; constexpr int kDelayedMutationLocal = 42; -std::shared_ptr delayedSelfMutation(Routines &routines) { +std::shared_ptr delayedSelfMutation( + Routines &routines, float delaySeconds = 0.0f) { auto program = std::make_shared(kOnLoadScript); // This is the compiled shape of: @@ -58,13 +66,40 @@ std::shared_ptr delayedSelfMutation(Routines &routines) { program->add(Instruction::newACTION( routines.getIndexByName("SetLocalBoolean"), 3)); program->add(Instruction(InstructionType::RETN)); - program->add(Instruction::newCONSTF(0.0f)); + program->add(Instruction::newCONSTF(delaySeconds)); program->add(Instruction::newACTION( routines.getIndexByName("DelayCommand"), 2)); program->add(Instruction(InstructionType::RETN)); return program; } +std::shared_ptr savedPermanentEffect() { + return Gff::Builder() + .type(0x1111) + .field(Gff::Field::newDword64("Id", 10)) + .field(Gff::Field::newWord("Type", 68)) + .field(Gff::Field::newWord( + "SubType", static_cast(DurationType::Permanent))) + .field(Gff::Field::newDword( + "CreatorId", kSavedEffectInvalidObjectId)) + .build(); +} + +std::shared_ptr savedWaitAction() { + auto seconds = Gff::Builder() + .type(1) + .field(Gff::Field::newDword("Type", 2)) + .field(Gff::Field::newFloat("Value", 1.0f)) + .build(); + return Gff::Builder() + .type(0) + .field(Gff::Field::newDword("ActionId", 30)) + .field(Gff::Field::newWord("GroupActionId", 7)) + .field(Gff::Field::newWord("NumParams", 1)) + .field(Gff::Field::newList("Paramaters", {seconds})) + .build(); +} + /** * What a module's authored entry hooks observe when the world is built. * @@ -86,6 +121,14 @@ struct EntryLifecycleFixture : TestWithParam { // than re-initializing mocks other suites already depend on. ON_CALL(engine.sceneModule().graphs(), get(_)) .WillByDefault(ReturnRef(sharedSceneGraph())); + ON_CALL(sharedSceneGraph(), testElevation(_, _)) + .WillByDefault(Invoke([this]( + const glm::vec3 &position, + scene::Collision &collision) { + collision.intersection = position; + collision.user = &candidateRoom; + return true; + })); EXPECT_CALL(engine.gameModule().portraits(), portraits()) .Times(AnyNumber()) .WillRepeatedly(ReturnRef(portraitRows)); @@ -179,6 +222,18 @@ struct EntryLifecycleFixture : TestWithParam { .build(); areaGit = Gff::Builder().build(); + auto &director = engine.resourceModule().director(); + EXPECT_CALL(director, prepareModuleLoad("module_b", _)) + .Times(AnyNumber()) + .WillRepeatedly(Invoke([this]( + const std::string &name, + std::shared_ptr) { + return PreparedModuleLoadTestFactory::validated( + name, moduleIfo, areaAre, areaGit); + })); + EXPECT_CALL(director, commitModuleLoad(_)) + .Times(AnyNumber()); + auto &gffs = engine.resourceModule().gffs(); EXPECT_CALL(gffs, get(_, _)) .Times(AnyNumber()) @@ -230,6 +285,7 @@ struct EntryLifecycleFixture : TestWithParam { std::unique_ptr game; std::unique_ptr routines; std::shared_ptr player; + Room candidateRoom {"candidate", glm::vec3(0.0f), nullptr, nullptr, nullptr}; std::vector> cameraNodes; std::shared_ptr emptyLayout {std::make_shared()}; std::shared_ptr moduleIfo; @@ -317,6 +373,7 @@ TEST_P(EntryLifecycleFixture, neither_hook_is_dispatched_twice) { TEST_P(EntryLifecycleFixture, authored_entry_work_on_a_revisit_takes_effect) { serveModule(/*savedModuleSnapshot=*/true); auto companion = game->newCreature(); + game->party().addAvailableMember(0, companion); game->party().addMember(0, companion); ASSERT_EQ(2, game->party().getSize()); @@ -368,6 +425,60 @@ TEST_P(EntryLifecycleFixture, a_restored_module_owns_and_executes_its_delayed_on EXPECT_EQ(0u, TestGameModule::delayedActionCount(*restored)); } +TEST_P(EntryLifecycleFixture, restored_runtime_state_is_visible_to_onload_and_stays_cleared) { + serveModule(/*savedModuleSnapshot=*/true); + auto savedRuntime = Gff::Builder() + .field(Gff::Field::newList( + "EffectList", {savedPermanentEffect()})) + .field(Gff::Field::newList( + "ActionList", {savedWaitAction()})) + .build(); + player->deserializeRuntimeState( + *savedRuntime, + SerializedIdentityContext::moduleGraph("module_b")); + ASSERT_TRUE(player->effects().empty()); + ASSERT_TRUE(player->actions().empty()); + + EXPECT_CALL(engine.resourceModule().scripts(), get(std::string(kOnLoadScript))) + .WillOnce(Invoke([this](const std::string &resRef) + -> std::shared_ptr { + dispatched.push_back({resRef, game->isLoadingFromSaveGame()}); + EXPECT_EQ(1u, player->effects().size()); + EXPECT_EQ(1u, player->actions().size()); + player->clearAllEffects(); + player->clearAllActions(); + return nullptr; + })); + + ASSERT_TRUE(game->loadModule("module_b", "", /*initialSaveRestore=*/true)); + EXPECT_TRUE(player->effects().empty()); + EXPECT_TRUE(player->actions().empty()); +} + +TEST_P(EntryLifecycleFixture, restored_runtime_state_is_visible_to_onenter) { + serveModule(/*savedModuleSnapshot=*/true); + auto savedRuntime = Gff::Builder() + .field(Gff::Field::newList( + "EffectList", {savedPermanentEffect()})) + .field(Gff::Field::newList( + "ActionList", {savedWaitAction()})) + .build(); + player->deserializeRuntimeState( + *savedRuntime, + SerializedIdentityContext::moduleGraph("module_b")); + + EXPECT_CALL(engine.resourceModule().scripts(), get(std::string(kOnEnterScript))) + .WillOnce(Invoke([this](const std::string &resRef) + -> std::shared_ptr { + dispatched.push_back({resRef, game->isLoadingFromSaveGame()}); + EXPECT_EQ(1u, player->effects().size()); + EXPECT_EQ(1u, player->actions().size()); + return nullptr; + })); + + ASSERT_TRUE(game->loadModule("module_b", "", /*initialSaveRestore=*/true)); +} + // Fresh and restored entry scripts use the same caller contract. This guards // the already-working side while the restored-world discriminator above pins // down the regression. @@ -395,16 +506,250 @@ TEST_P(EntryLifecycleFixture, a_fresh_module_owns_and_executes_its_delayed_onloa EXPECT_EQ(0u, TestGameModule::delayedActionCount(*fresh)); } -// The predicates that decide what gets restored keep their own meaning; only -// the authored hooks were wrongly hanging off them. -TEST(EntryLifecycleContexts, restoration_predicates_are_unchanged) { +TEST_P(EntryLifecycleFixture, ordinary_transition_reconstructs_party_action_queue_once) { + serveModule(/*savedModuleSnapshot=*/false); + ASSERT_TRUE(game->loadModule("module_b")); + + auto outgoing = game->newAction(5.0f); + player->addAction(outgoing); + ASSERT_EQ(1u, player->actions().size()); + + ASSERT_TRUE(game->loadModule("module_b")); + + ASSERT_EQ(1u, player->actions().size()); + EXPECT_TRUE(outgoing->isCancelled()); + EXPECT_NE(outgoing, player->actions().front()); + ASSERT_TRUE(player->actions().front()->originalSavedAction()); + EXPECT_EQ(30u, player->actions().front()->originalSavedAction()->actionId); + + // A second transition snapshots the reconstructed queue rather than + // appending another copy of the first transition's record. + ASSERT_TRUE(game->loadModule("module_b")); + ASSERT_EQ(1u, player->actions().size()); + ASSERT_TRUE(player->actions().front()->originalSavedAction()); + EXPECT_EQ(30u, player->actions().front()->originalSavedAction()->actionId); +} + +TEST_P(EntryLifecycleFixture, ordinary_transition_keeps_only_exact_live_action_target) { + serveModule(/*savedModuleSnapshot=*/false); + auto companion = game->newCreature(); + ASSERT_TRUE(game->party().addAvailableMember(0, companion)); + ASSERT_TRUE(game->party().addMember(0, companion)); + ASSERT_TRUE(game->loadModule("module_b")); + + auto outgoing = game->newAction(companion); + player->addAction(outgoing); + + ASSERT_TRUE(game->loadModule("module_b")); + + ASSERT_EQ(1u, player->actions().size()); + auto restored = std::dynamic_pointer_cast( + player->actions().front()); + ASSERT_TRUE(restored); + EXPECT_EQ(companion, restored->target()); + EXPECT_TRUE(outgoing->isCancelled()); +} + +TEST_P(EntryLifecycleFixture, ordinary_transition_drops_outgoing_area_action_target) { + serveModule(/*savedModuleSnapshot=*/false); + ASSERT_TRUE(game->loadModule("module_b")); + auto outgoingTarget = game->newCreature(); + game->module()->area()->add(outgoingTarget); + auto outgoing = game->newAction(outgoingTarget); + player->addAction(outgoing); + + ASSERT_TRUE(game->loadModule("module_b")); + + EXPECT_TRUE(outgoing->isCancelled()); + EXPECT_FALSE(game->isRuntimeObjectLive(*outgoingTarget)); + EXPECT_TRUE(player->actions().empty()); +} + +TEST_P(EntryLifecycleFixture, controlled_companion_uses_same_transition_continuity) { + serveModule(/*savedModuleSnapshot=*/false); + auto controlled = game->newCreature(); + ASSERT_TRUE(game->party().addAvailableMember(0, controlled)); + game->party().setControlledMember(0, controlled); + ASSERT_EQ(controlled, game->party().player()); + ASSERT_EQ(player, game->party().actualPlayer()); + ASSERT_TRUE(game->loadModule("module_b")); + + auto outgoing = game->newAction(5.0f); + controlled->addAction(outgoing); + + ASSERT_TRUE(game->loadModule("module_b")); + + EXPECT_EQ(controlled, game->party().player()); + EXPECT_EQ(player, game->party().actualPlayer()); + ASSERT_EQ(1u, controlled->actions().size()); + EXPECT_TRUE(outgoing->isCancelled()); + ASSERT_TRUE(controlled->actions().front()->originalSavedAction()); + EXPECT_EQ( + 30u, + controlled->actions().front()->originalSavedAction()->actionId); +} + +TEST_P(EntryLifecycleFixture, k2_puppet_uses_same_transition_continuity) { + if (GetParam() != GameID::TSL) GTEST_SKIP(); + serveModule(/*savedModuleSnapshot=*/false); + auto puppet = game->newCreature(); + ASSERT_TRUE(game->party().addAvailablePuppet(0, puppet)); + ASSERT_TRUE(game->party().addPuppet(0, puppet)); + ASSERT_TRUE(game->loadModule("module_b")); + + auto outgoing = game->newAction(5.0f); + puppet->addAction(outgoing); + + ASSERT_TRUE(game->loadModule("module_b")); + + ASSERT_EQ(1u, puppet->actions().size()); + EXPECT_TRUE(outgoing->isCancelled()); + ASSERT_TRUE(puppet->actions().front()->originalSavedAction()); + EXPECT_EQ(30u, puppet->actions().front()->originalSavedAction()->actionId); +} + +TEST_P(EntryLifecycleFixture, ordinary_transition_preserves_party_delay_due_time) { + serveModule(/*savedModuleSnapshot=*/false); + ASSERT_TRUE(game->loadModule("module_b")); + TestGameModule::setSnapshotWorldTime(*game, 3, 1200, 2); + + auto program = std::make_shared("party_delay"); + program->add(Instruction(InstructionType::RETN)); + auto state = std::make_shared(); + state->program = std::move(program); + state->insOffset = 13; + state->globals = {Variable::ofInt(7)}; + auto context = std::make_shared(); + context->savedState = std::move(state); + player->delayAction( + game->newAction(std::move(context)), 2.0f); + ASSERT_EQ(1u, TestGameModule::delayedActionCount(*player)); + + ASSERT_TRUE(game->loadModule("module_b")); + + EXPECT_EQ(0u, TestGameModule::delayedActionCount(*player)); + ASSERT_TRUE(game->module()); + EXPECT_EQ(1u, game->module()->pendingSavedEventCount()); + + game->update(1.0f); + EXPECT_EQ(1u, game->module()->pendingSavedEventCount()); + game->update(1.1f); + EXPECT_EQ(0u, game->module()->pendingSavedEventCount()); +} + +TEST_P(EntryLifecycleFixture, failed_destination_retires_attached_session_creatures_before_teardown) { + serveModule(/*savedModuleSnapshot=*/false); + + std::shared_ptr controlled; + std::shared_ptr puppet; + if (GetParam() == GameID::TSL) { + controlled = game->newCreature(); + ASSERT_TRUE(game->party().addAvailableMember(0, controlled)); + game->party().setControlledMember(0, controlled); + ASSERT_EQ(controlled, game->party().player()); + + puppet = game->newCreature(); + ASSERT_TRUE(game->party().addAvailablePuppet(0, puppet)); + ASSERT_TRUE(game->party().addPuppet(0, puppet)); + } + + auto expectedLeader = game->party().player(); + std::shared_ptr failedArea; + std::shared_ptr failedTrigger; + EXPECT_CALL(engine.resourceModule().scripts(), get(std::string(kOnEnterScript))) + .WillOnce(Invoke([this, &failedArea, &failedTrigger, expectedLeader, puppet]( + const std::string &resRef) + -> std::shared_ptr { + dispatched.push_back({resRef, game->isLoadingFromSaveGame()}); + failedArea = game->module()->area(); + TestGameModule::setAreaRuntimePath( + *expectedLeader, failedArea->pathfinder()); + failedTrigger = game->newTrigger(); + failedArea->add(failedTrigger); + failedTrigger->addTenant(expectedLeader); + if (puppet) { + TestGameModule::setAreaRuntimePath( + *puppet, failedArea->pathfinder()); + failedTrigger->addTenant(puppet); + } + throw std::runtime_error("injected Area OnEnter failure"); + })); + + ASSERT_FALSE(game->loadModule("module_b")); + ASSERT_TRUE(failedArea); + ASSERT_TRUE(failedTrigger); + EXPECT_FALSE(game->module()); + EXPECT_EQ(nullptr, expectedLeader->room()); + EXPECT_FALSE(candidateRoom.tenants().count(expectedLeader.get())); + EXPECT_FALSE(TestGameModule::hasAreaRuntimePath(*expectedLeader)); + EXPECT_FALSE(failedTrigger->isTenant(expectedLeader)); + const auto &creatures = failedArea->getObjectsByType(ObjectType::Creature); + EXPECT_EQ(creatures.end(), std::find(creatures.begin(), creatures.end(), expectedLeader)); + EXPECT_EQ(Game::Screen::MainMenu, game->currentScreen()); + EXPECT_FALSE(game->party().player()); + EXPECT_FALSE(game->isRuntimeObjectLive(*expectedLeader)); + EXPECT_FALSE(game->getObjectById(expectedLeader->id())); + + if (controlled) { + EXPECT_FALSE(game->party().rosterCreature({RosterKind::Npc, 0})); + EXPECT_EQ(nullptr, player->room()) + << "the parked canonical PC was never attached to the candidate"; + } + if (puppet) { + EXPECT_EQ(nullptr, puppet->room()); + EXPECT_FALSE(candidateRoom.tenants().count(puppet.get())); + EXPECT_FALSE(TestGameModule::hasAreaRuntimePath(*puppet)); + EXPECT_FALSE(failedTrigger->isTenant(puppet)); + EXPECT_FALSE(game->party().rosterCreature({RosterKind::Puppet, 0})); + EXPECT_FALSE(game->isRuntimeObjectLive(*puppet)); + EXPECT_EQ(creatures.end(), std::find(creatures.begin(), creatures.end(), puppet)); + } + + // A later whole-session retirement sees already-detached objects and is + // intentionally harmless. + game->retireRuntimeSession(); +} + +TEST_P(EntryLifecycleFixture, failed_destination_before_party_placement_is_harmless) { + serveModule(/*savedModuleSnapshot=*/false); + + std::shared_ptr failedArea; + EXPECT_CALL(engine.resourceModule().scripts(), get(std::string(kOnLoadScript))) + .WillOnce(Invoke([this, &failedArea](const std::string &resRef) + -> std::shared_ptr { + dispatched.push_back({resRef, game->isLoadingFromSaveGame()}); + failedArea = game->module()->area(); + throw std::runtime_error("injected Module OnLoad failure"); + })); + + ASSERT_FALSE(game->loadModule("module_b")); + ASSERT_TRUE(failedArea); + EXPECT_FALSE(game->module()); + EXPECT_EQ(nullptr, player->room()); + EXPECT_FALSE(candidateRoom.tenants().count(player.get())); + EXPECT_TRUE(failedArea->getObjectsByType(ObjectType::Creature).empty()); + EXPECT_EQ(Game::Screen::MainMenu, game->currentScreen()); + EXPECT_FALSE(game->party().player()); + EXPECT_FALSE(game->isRuntimeObjectLive(*player)); + EXPECT_FALSE(game->getObjectById(player->id())); +} + +// Session restoration, saved-world identity and placement are independent. +TEST(EntryLifecycleContexts, restoration_predicates_are_orthogonal) { EXPECT_FALSE(restoresSavedWorld(ModuleLoadContext::FreshModule)); + EXPECT_FALSE(restoresSavedWorld(ModuleLoadContext::InitialTemplateRestore)); EXPECT_TRUE(restoresSavedWorld(ModuleLoadContext::SavedModuleTransition)); EXPECT_TRUE(restoresSavedWorld(ModuleLoadContext::InitialSaveRestore)); EXPECT_FALSE(restoresSavedSession(ModuleLoadContext::FreshModule)); EXPECT_FALSE(restoresSavedSession(ModuleLoadContext::SavedModuleTransition)); + EXPECT_TRUE(restoresSavedSession(ModuleLoadContext::InitialTemplateRestore)); EXPECT_TRUE(restoresSavedSession(ModuleLoadContext::InitialSaveRestore)); + + EXPECT_FALSE(preservesSavedPlacement(ModuleLoadContext::FreshModule)); + EXPECT_FALSE(preservesSavedPlacement(ModuleLoadContext::InitialTemplateRestore)); + EXPECT_FALSE(preservesSavedPlacement(ModuleLoadContext::SavedModuleTransition)); + EXPECT_TRUE(preservesSavedPlacement(ModuleLoadContext::InitialSaveRestore)); } INSTANTIATE_TEST_SUITE_P( diff --git a/test/game/interaction.cpp b/test/game/interaction.cpp index 046b55226..efb6ca7d6 100644 --- a/test/game/interaction.cpp +++ b/test/game/interaction.cpp @@ -63,8 +63,9 @@ class CreatureInteractionTest : public Test { _leader = _game->newCreature(); _leader->setFaction(kLeaderFaction); - _game->party().addMember(0, _leader); + _game->party().addMember(kNpcPlayer, _leader); _game->party().setPlayer(_leader); + _game->party().setActualPlayer(_leader); _other = _game->newCreature(); _other->setFaction(kOtherFaction); diff --git a/test/game/loadfromsavegame.cpp b/test/game/loadfromsavegame.cpp index a6ecee50a..edf0203b6 100644 --- a/test/game/loadfromsavegame.cpp +++ b/test/game/loadfromsavegame.cpp @@ -19,6 +19,7 @@ #include "reone/game/object/creature.h" #include "reone/game/savegame.h" #include "reone/game/script/routines.h" +#include "reone/resource/gff.h" #include "reone/resource/saveworkingstate.h" #include "reone/script/executioncontext.h" #include "reone/script/variable.h" @@ -77,6 +78,21 @@ struct LoadFromSaveGameFixture : TestWithParam { EXPECT_CALL(director, saveNames()) .Times(AnyNumber()) .WillRepeatedly(Return(std::set {})); + + destinationIfo = Gff::Builder() + .field(Gff::Field::newResRef( + "Mod_Entry_Area", "area_b")) + .build(); + destinationAre = Gff::Builder().build(); + destinationGit = Gff::Builder().build(); + EXPECT_CALL(director, prepareModuleLoad(_, _)) + .Times(AnyNumber()) + .WillRepeatedly(Invoke([this]( + const std::string &name, + std::shared_ptr) { + return PreparedModuleLoadTestFactory::validated( + name, destinationIfo, destinationAre, destinationGit); + })); } /** The routine's answer, the way a compiled script asks for it. */ @@ -122,6 +138,9 @@ struct LoadFromSaveGameFixture : TestWithParam { std::make_shared()}; SaveSlotDescriptor sourceSlot; std::optional observed; + std::shared_ptr destinationIfo; + std::shared_ptr destinationAre; + std::shared_ptr destinationGit; }; } // namespace diff --git a/test/game/messagebus.cpp b/test/game/messagebus.cpp index a8dad9999..d729e9bf8 100644 --- a/test/game/messagebus.cpp +++ b/test/game/messagebus.cpp @@ -17,10 +17,45 @@ #include +#include "../fixtures/engine.h" +#include "../fixtures/game.h" +#include "../fixtures/scene.h" + +#include "reone/game/game.h" #include "reone/game/messagebus.h" +#include "reone/game/object/area.h" +#include "reone/game/object/creature.h" using namespace reone; using namespace reone::game; +using namespace reone::resource; +using namespace testing; + +namespace { + +class MessageBusHarness { +public: + MessageBusHarness() { + engine.init(); + ON_CALL(engine.sceneModule().graphs(), get(_)) + .WillByDefault(ReturnRef(sceneGraph)); + game = std::make_unique( + GameID::KotOR, + "", + engine.options(), + engine.services(), + console); + } + + std::shared_ptr creature() { + return game->newCreature(); + } + + TestEngine engine; + NiceMock sceneGraph; + StubConsole console; + std::unique_ptr game; +}; struct Msg { uint32_t speakerId; @@ -33,24 +68,30 @@ struct Msg { } }; +} // namespace + TEST(MessageBus, test_basic) { + MessageBusHarness harness; MessageBus bus; - bus.addListener(10, "foo", 1); - bus.addListener(11, "foo", 1); - bus.addListener(11, "bar", 2); + auto first = harness.creature(); + auto second = harness.creature(); + bus.addListener(first, "foo", 1); + bus.addListener(second, "foo", 1); + bus.addListener(second, "bar", 2); bus.addMessage(20, "foo", TalkVolume::Shout); bus.addMessage(20, "bar", TalkVolume::Shout); std::vector expected = { - {20, 10, 1, TalkVolume::Shout}, - {20, 11, 1, TalkVolume::Shout}, - {20, 11, 2, TalkVolume::Shout}}; + {20, first->id(), 1, TalkVolume::Shout}, + {20, second->id(), 1, TalkVolume::Shout}, + {20, second->id(), 2, TalkVolume::Shout}}; std::vector got; - bus.update([&](uint32_t speakerId, uint32_t listenerId, int32_t number, TalkVolume volume) { - got.push_back({speakerId, listenerId, number, volume}); + bus.update([&](uint32_t speakerId, const auto &listener, + int32_t number, TalkVolume volume) { + got.push_back({speakerId, listener->id(), number, volume}); }); ASSERT_EQ(expected.size(), got.size()); @@ -60,20 +101,23 @@ TEST(MessageBus, test_basic) { } TEST(MessageBus, test_update_number) { + MessageBusHarness harness; MessageBus bus; - bus.addListener(10, "foo", 1); - bus.addListener(10, "foo", 2); + auto listener = harness.creature(); + bus.addListener(listener, "foo", 1); + bus.addListener(listener, "foo", 2); bus.addMessage(20, "foo", TalkVolume::Shout); std::vector expected = { - {20, 10, 2, TalkVolume::Shout}, + {20, listener->id(), 2, TalkVolume::Shout}, }; std::vector got; - bus.update([&](uint32_t speakerId, uint32_t listenerId, int32_t number, TalkVolume volume) { - got.push_back({speakerId, listenerId, number, volume}); + bus.update([&](uint32_t speakerId, const auto &resolved, + int32_t number, TalkVolume volume) { + got.push_back({speakerId, resolved->id(), number, volume}); }); ASSERT_EQ(expected.size(), got.size()); @@ -81,3 +125,152 @@ TEST(MessageBus, test_update_number) { EXPECT_EQ(expected[i], got[i]); } } + +TEST(MessageBus, dead_listener_is_pruned_without_dispatch) { + MessageBusHarness harness; + MessageBus bus; + auto listener = harness.creature(); + bus.addListener(listener, "foo", 15); + ASSERT_EQ(1u, bus.listenerCount()); + harness.game->destroyRuntimeObjectGraph(listener); + bus.addMessage(10, "foo", TalkVolume::Talk); + int dispatches = 0; + + bus.update([&](uint32_t, const auto &, int32_t, TalkVolume) { + ++dispatches; + }); + + EXPECT_EQ(0, dispatches); + EXPECT_EQ(0u, bus.listenerCount()); +} + +TEST(MessageBus, maintenance_prunes_dead_listener_without_a_message) { + MessageBusHarness harness; + MessageBus bus; + auto listener = harness.creature(); + bus.addListener(listener, "unused", 1); + harness.game->destroyRuntimeObjectGraph(listener); + + bus.update([](uint32_t, const auto &, int32_t, TalkVolume) {}); + + EXPECT_EQ(0u, bus.listenerCount()); +} + +TEST(MessageBus, one_dead_listener_does_not_disturb_a_live_listener) { + MessageBusHarness harness; + MessageBus bus; + auto dead = harness.creature(); + auto live = harness.creature(); + bus.addListener(dead, "foo", 1); + bus.addListener(live, "foo", 2); + harness.game->destroyRuntimeObjectGraph(dead); + bus.addMessage(20, "foo", TalkVolume::Shout); + std::vector listeners; + + bus.update([&](uint32_t, const auto &listener, int32_t, TalkVolume) { + listeners.push_back(listener->id()); + }); + + EXPECT_EQ(std::vector({live->id()}), listeners); + EXPECT_EQ(1u, bus.listenerCount()); +} + +TEST(MessageBus, destruction_during_dispatch_is_iteration_safe) { + MessageBusHarness harness; + MessageBus bus; + auto first = harness.creature(); + auto second = harness.creature(); + bus.addListener(first, "foo", 1); + bus.addListener(second, "foo", 2); + bus.addMessage(20, "foo", TalkVolume::Shout); + std::vector listeners; + + bus.update([&](uint32_t, const auto &listener, int32_t, TalkVolume) { + listeners.push_back(listener->id()); + harness.game->destroyRuntimeObjectGraph(first); + harness.game->destroyRuntimeObjectGraph(second); + }); + + EXPECT_EQ(std::vector({first->id()}), listeners); + EXPECT_EQ(0u, bus.listenerCount()); +} + +TEST(MessageBus, listener_added_during_dispatch_starts_with_the_next_message) { + MessageBusHarness harness; + MessageBus bus; + auto first = harness.creature(); + auto second = harness.creature(); + bus.addListener(first, "foo", 1); + bus.addMessage(20, "foo", TalkVolume::Shout); + std::vector listeners; + + bus.update([&](uint32_t, const auto &listener, int32_t, TalkVolume) { + listeners.push_back(listener->id()); + bus.addListener(second, "foo", 2); + }); + EXPECT_EQ(std::vector({first->id()}), listeners); + + listeners.clear(); + bus.addMessage(20, "foo", TalkVolume::Shout); + bus.update([&](uint32_t, const auto &listener, int32_t, TalkVolume) { + listeners.push_back(listener->id()); + }); + + EXPECT_EQ( + std::vector({first->id(), second->id()}), + listeners); +} + +TEST(MessageBus, newer_runtime_object_does_not_inherit_a_dead_registration) { + MessageBusHarness harness; + MessageBus bus; + auto stale = harness.creature(); + const uint32_t staleId = stale->id(); + bus.addListener(stale, "foo", 1); + harness.game->destroyRuntimeObjectGraph(stale); + auto replacement = harness.creature(); + ASSERT_NE(staleId, replacement->id()); + bus.addListener(replacement, "foo", 2); + bus.addMessage(20, "foo", TalkVolume::Shout); + std::vector listeners; + + bus.update([&](uint32_t, const auto &listener, int32_t, TalkVolume) { + listeners.push_back(listener->id()); + }); + + EXPECT_EQ(std::vector({replacement->id()}), listeners); + EXPECT_EQ(1u, bus.listenerCount()); +} + +TEST(MessageBus, full_session_retirement_invalidates_listener_registration) { + MessageBusHarness harness; + MessageBus bus; + auto listener = harness.creature(); + bus.addListener(listener, "foo", 1); + + harness.game->retireRuntimeSession(); + bus.addMessage(20, "foo", TalkVolume::Talk); + int dispatches = 0; + bus.update([&](uint32_t, const auto &, int32_t, TalkVolume) { + ++dispatches; + }); + + EXPECT_FALSE(harness.game->isRuntimeObjectLive(*listener)); + EXPECT_EQ(0, dispatches); + EXPECT_EQ(0u, bus.listenerCount()); +} + +TEST(MessageBus, area_dispatch_prunes_the_destroyed_listener_in_endar_shape) { + MessageBusHarness harness; + auto area = harness.game->newArea(); + auto listener = harness.creature(); + auto speaker = harness.creature(); + area->messageBus().addListener(listener, "foo", 15); + area->messageBus().addMessage( + speaker->id(), "foo", TalkVolume::Talk); + harness.game->destroyRuntimeObjectGraph(listener); + + EXPECT_NO_THROW(area->updateMessageBus()); + EXPECT_EQ(0u, area->messageBus().listenerCount()); + EXPECT_TRUE(harness.game->isRuntimeObjectLive(*speaker)); +} diff --git a/test/game/modulesnapshot.cpp b/test/game/modulesnapshot.cpp index 9c6785ae5..43ba6f6ab 100644 --- a/test/game/modulesnapshot.cpp +++ b/test/game/modulesnapshot.cpp @@ -10,6 +10,8 @@ #include #include +#include + #include "../fixtures/engine.h" #include "../fixtures/game.h" @@ -21,6 +23,9 @@ #include "reone/game/action/movetolocation.h" #include "reone/game/action/movetoobject.h" #include "reone/game/action/startconversation.h" +#include "reone/game/effect.h" +#include "reone/game/d20/class.h" +#include "reone/game/d20/classes.h" #include "reone/game/game.h" #include "reone/game/modulesnapshot.h" #include "reone/game/object/area.h" @@ -49,6 +54,27 @@ using namespace reone::resource; using namespace reone::script; using namespace testing; +namespace { + +SerializedIdentityContext snapshotIdentityContext() { + return SerializedIdentityContext::moduleGraph("module003"); +} + +class CompleteReferenceEffect : public Effect { +public: + CompleteReferenceEffect( + const std::shared_ptr &creator, + const std::array, 4> &objects) : + Effect(EffectType::Beam) { + setSaveFacingCreator(creator); + for (size_t index = 0; index < objects.size(); ++index) { + setSaveFacingObject(index, objects[index]); + } + } +}; + +} // namespace + void reone::game::TestGameModule::configureModuleSnapshot( Game &game, std::shared_ptr area, @@ -110,8 +136,9 @@ void reone::game::TestGameModule::setSnapshotMinutesPerHour( } void reone::game::TestGameModule::deserializeSnapshotRuntimeState( - Object &object, const Gff &gff) { - object.deserializeRuntimeState(gff); + Object &object, const Gff &gff, + const SerializedIdentityContext &identityContext) { + object.deserializeRuntimeState(gff, identityContext); } void reone::game::TestGameModule::setSnapshotObjectId( @@ -203,6 +230,8 @@ struct SnapshotFixture : Test { void captureResourceShadows() { auto ifo = Gff::Builder().type(0xffffffff) .field(Gff::Field::newDword("Mod_NextObjId0", 50)) + .field(Gff::Field::newDword("Mod_CalendarDay", 99)) + .field(Gff::Field::newDword("Mod_TimeOfDay", 99)) .field(Gff::Field::newCExoString("FutureIfo", "preserve-ifo")) .build(); auto are = Gff::Builder().type(0xffffffff) @@ -211,6 +240,7 @@ struct SnapshotFixture : Test { auto staleDoor = Gff::Builder().type(8) .field(Gff::Field::newDword("ObjectId", 999)).build(); auto git = Gff::Builder().type(0xffffffff) + .field(Gff::Field::newByte("UseTemplates", 1)) .field(Gff::Field::newCExoString("FutureGit", "preserve-git")) .field(Gff::Field::newList("Door List", {staleDoor})).build(); game.captureSaveResourceShadow( @@ -232,7 +262,9 @@ struct SnapshotFixture : Test { .field(Gff::Field::newCExoString("FutureDoor", "preserve-door")) .build(); door->captureSaveRecord( - *source, {SaveRecordOriginKind::ActiveGitObject, "tat_m17ab"}); + *source, + SerializedIdentityContext::moduleGraph("tat_m17ab"), + {SaveRecordOriginKind::ActiveGitObject, "tat_m17ab"}); door->open(); door->setLocked(true); TestGameModule::addSnapshotObject(*area, door); @@ -246,6 +278,33 @@ struct SnapshotFixture : Test { std::shared_ptr player; }; +struct TslPartySnapshotFixture : Test { + TslPartySnapshotFixture() : + game(GameID::TSL, "", engine.options(), engine.services(), console) { + } + + void SetUp() override { + area = game.newArea(); + player = game.newCreature(); + TestGameModule::configureModuleSnapshot( + game, area, player, "301nar", "301nar"); + TestGameModule::addSnapshotObject(*area, player); + auto empty = Gff::Builder().type(0xffffffff).build(); + game.captureSaveResourceShadow( + {SaveResourceKind::ModuleIfo, "301nar"}, *empty); + game.captureSaveResourceShadow( + {SaveResourceKind::AreaAre, "301nar"}, *empty); + game.captureSaveResourceShadow( + {SaveResourceKind::AreaGit, "301nar"}, *empty); + } + + TestEngine &engine {testEngine()}; + StubConsole console; + Game game; + std::shared_ptr area; + std::shared_ptr player; +}; + TEST(ModuleSnapshot, reports_no_playable_module_without_exposing_partial_bytes) { TestEngine &engine = testEngine(); StubConsole console; @@ -260,6 +319,84 @@ TEST(ModuleSnapshot, reports_no_playable_module_without_exposing_partial_bytes) EXPECT_EQ(TestGameModule::nextObjectId(game), before); } +TEST_F(TslPartySnapshotFixture, retained_npc_and_puppet_are_not_git_creatures) { + auto npc = game.newCreature(); + npc->setTag("remote"); + ASSERT_TRUE(game.party().addAvailableMember(0, npc)); + ASSERT_TRUE(game.party().addMember(0, npc)); + TestGameModule::addSnapshotObject(*area, npc); + + auto worldCreature = game.newCreature(); + worldCreature->setTag("remote"); + TestGameModule::addSnapshotObject(*area, worldCreature); + + auto puppet = game.newCreature(); + puppet->setTag("remote"); + const auto puppetIdentity = SerializedObjectIdentity { + SerializedIdentityContext::detachedRecord("availpup0.utc"), 77u}; + puppet->assignSerializedObjectIdentity(puppetIdentity); + ASSERT_TRUE(game.party().addAvailablePuppet(0, puppet)); + ASSERT_TRUE(game.party().addPuppet(0, puppet)); + TestGameModule::addSnapshotObject(*area, puppet); + + ASSERT_TRUE(game.party().isRetainedRuntimeRepresentation(*npc)); + ASSERT_TRUE(game.party().isRetainedRuntimeRepresentation(*puppet)); + ASSERT_FALSE(game.party().isRetainedRuntimeRepresentation(*worldCreature)); + + for (int cycle = 0; cycle < 10; ++cycle) { + SCOPED_TRACE(cycle); + auto saved = ModuleSnapshotBuilder(game, "301nar").build(); + ASSERT_TRUE(saved) << saved.message; + + const auto &creatures = saved.snapshot->git->getList("Creature List"); + ASSERT_EQ(1u, creatures.size()); + EXPECT_EQ("remote", creatures.front()->getString("Tag")); + EXPECT_EQ(worldCreature->id(), creatures.front()->getUint("ObjectId")); + EXPECT_EQ(puppet->id(), saved.snapshot->ifo->getUint("Mod_NextObjId0")); + + EXPECT_TRUE(game.party().isPuppet(0)); + EXPECT_EQ( + puppet, + game.party().rosterCreature({RosterKind::Puppet, 0})); + EXPECT_EQ(puppetIdentity, puppet->serializedObjectIdentity()); + } +} + +TEST_F(TslPartySnapshotFixture, inactive_bound_puppet_remains_a_git_creature) { + auto inactive = game.newCreature(); + inactive->setTag("inactive_remote"); + ASSERT_TRUE(game.party().addAvailablePuppet(0, inactive)); + ASSERT_FALSE(game.party().isPuppet(0)); + TestGameModule::addSnapshotObject(*area, inactive); + + EXPECT_FALSE(game.party().isRetainedRuntimeRepresentation(*inactive)); + + auto saved = ModuleSnapshotBuilder(game, "301nar").build(); + ASSERT_TRUE(saved) << saved.message; + auto record = recordById( + *saved.snapshot->git, "Creature List", inactive->id()); + ASSERT_TRUE(record); + EXPECT_EQ("inactive_remote", record->getString("Tag")); +} + +TEST_F(SnapshotFixture, k1_active_member_exclusion_is_unchanged) { + auto member = game.newCreature(); + member->setTag("same_tag"); + ASSERT_TRUE(game.party().addAvailableMember(0, member)); + ASSERT_TRUE(game.party().addMember(0, member)); + TestGameModule::addSnapshotObject(*area, member); + + auto worldCreature = game.newCreature(); + worldCreature->setTag("same_tag"); + TestGameModule::addSnapshotObject(*area, worldCreature); + + auto saved = ModuleSnapshotBuilder(game, "module_k1_party").build(); + ASSERT_TRUE(saved) << saved.message; + const auto &creatures = saved.snapshot->git->getList("Creature List"); + ASSERT_EQ(1u, creatures.size()); + EXPECT_EQ(worldCreature->id(), creatures.front()->getUint("ObjectId")); +} + TEST_F(SnapshotFixture, writes_and_reopens_complete_deterministic_module_state) { game.setCustomToken(9, "nine"); game.setCustomToken(2, "two"); @@ -272,8 +409,8 @@ TEST_F(SnapshotFixture, writes_and_reopens_complete_deterministic_module_state) area->setStealthXPDecrement(5); TestGameModule::setSnapshotWorldTime(game, 3, 1000, 5); - player->setCurrentHitPoints(17); player->setMaxHitPoints(35); + player->setCurrentHitPoints(17); player->setLocalBoolean(31, true); player->setLocalNumber(4, 23); player->applyEffect(game.newEffect(EffectType::Haste), DurationType::Temporary, 30.0f); @@ -305,7 +442,9 @@ TEST_F(SnapshotFixture, writes_and_reopens_complete_deterministic_module_state) *camera, 7, {4.0f, 5.0f, 6.0f}, glm::quat(1.0f, 0.0f, 0.0f, 0.0f), 12.0f, 1.5f, 55.0f, 8.0f); camera->captureSaveRecord( - *cameraSource, {SaveRecordOriginKind::ActiveGitObject, "tat_m17ab"}); + *cameraSource, + SerializedIdentityContext::moduleGraph("tat_m17ab"), + {SaveRecordOriginKind::ActiveGitObject, "tat_m17ab"}); for (const auto &object : std::vector> { placeable, worldItem, trigger, encounter, store, waypoint, sound, camera}) { @@ -318,8 +457,20 @@ TEST_F(SnapshotFixture, writes_and_reopens_complete_deterministic_module_state) .field(Gff::Field::newCExoString("FutureTrigger", "preserve-trigger")) .build(); trigger->captureSaveRecord( - *triggerShadow, {SaveRecordOriginKind::ActiveGitObject, "tat_m17ab"}); - TestGameModule::deserializeSnapshotRuntimeState(*trigger, *triggerShadow); + *triggerShadow, + SerializedIdentityContext::moduleGraph("tat_m17ab"), + {SaveRecordOriginKind::ActiveGitObject, "tat_m17ab"}); + TestGameModule::deserializeSnapshotRuntimeState( + *trigger, *triggerShadow, + SerializedIdentityContext::moduleGraph("tat_m17ab")); + game.registerSavedObjectIdentity( + player->id(), + player, + SerializedIdentityContext::moduleGraph("tat_m17ab")); + game.registerSavedObjectIdentity( + trigger->id(), + trigger, + SerializedIdentityContext::moduleGraph("tat_m17ab")); game.resolveSavedObjectReferences(); auto limbo = game.newCreature(); @@ -341,6 +492,14 @@ TEST_F(SnapshotFixture, writes_and_reopens_complete_deterministic_module_state) EXPECT_EQ(first.snapshot->archiveBytes, second.snapshot->archiveBytes); EXPECT_EQ(first.snapshot->target, ResourceId("module003", ResType::Sav)); + // The module archive is frozen before the live Area attachment retires. + // Runtime execution must not leak into the destination merely because the + // party creature survives, while persistent gameplay effects remain live. + std::set retainedObjects {player.get()}; + player->retireAreaRuntime(area->pathfinder(), retainedObjects); + EXPECT_TRUE(player->actions().empty()); + ASSERT_EQ(1u, player->effects().size()); + auto ifo = readGff(first.snapshot->ifoBytes); auto are = readGff(first.snapshot->areBytes); auto git = readGff(first.snapshot->gitBytes); @@ -361,13 +520,15 @@ TEST_F(SnapshotFixture, writes_and_reopens_complete_deterministic_module_state) EXPECT_EQ(reparsedTokens.at(2), "two"); EXPECT_EQ(reparsedTokens.at(9), "nine"); Module e2Module(1000, game, engine.services()); - TestGameModule::deserializeSnapshotRuntimeState(e2Module, *ifo); + TestGameModule::deserializeSnapshotRuntimeState( + e2Module, *ifo, snapshotIdentityContext()); EXPECT_TRUE(e2Module.getLocalBoolean(7)); EXPECT_EQ(e2Module.getLocalNumber(3), 44); ASSERT_EQ(ifo->getList("EventQueue").size(), 1); EXPECT_EQ(ifo->getList("EventQueue")[0]->getUint("Day"), 8u); EXPECT_EQ(ifo->getList("EventQueue")[0]->getUint("Time"), 9123u); - const auto e2Events = SavedEventQueue::fromGff(*ifo); + const auto e2Events = SavedEventQueue::fromGff( + *ifo, snapshotIdentityContext()); ASSERT_EQ(e2Events.events.size(), 1); EXPECT_EQ(e2Events.events.front().day, 8u); EXPECT_EQ(e2Events.events.front().time, 9123u); @@ -380,10 +541,12 @@ TEST_F(SnapshotFixture, writes_and_reopens_complete_deterministic_module_state) ASSERT_EQ(playerRecord->getList("ActionList").size(), 1); EXPECT_FLOAT_EQ( std::get(SavedActionRecord::fromGff( - *playerRecord->getList("ActionList").front()).parameters.front().payload), + *playerRecord->getList("ActionList").front(), + snapshotIdentityContext()).parameters.front().payload), 20.0f); auto effect = EffectInstance::fromGff( - *playerRecord->getList("EffectList").front()); + *playerRecord->getList("EffectList").front(), + snapshotIdentityContext()); EXPECT_EQ(effect.expiryDay, 3u); // Twenty seconds remaining. World-time milliseconds are real milliseconds, // as in CWorldTimer, so this is 1000 + 20 * 1000. @@ -391,7 +554,8 @@ TEST_F(SnapshotFixture, writes_and_reopens_complete_deterministic_module_state) ASSERT_TRUE(game.remainingEffectDuration(effect)); EXPECT_FLOAT_EQ(*game.remainingEffectDuration(effect), 20.0f); Creature e2Creature(player->id() + 1000, "", game, engine.services()); - TestGameModule::deserializeSnapshotRuntimeState(e2Creature, *playerRecord); + TestGameModule::deserializeSnapshotRuntimeState( + e2Creature, *playerRecord, snapshotIdentityContext()); EXPECT_TRUE(e2Creature.getLocalBoolean(31)); EXPECT_EQ(e2Creature.getLocalNumber(4), 23); ASSERT_EQ(e2Creature.savedEffects().size(), 1); @@ -444,6 +608,52 @@ TEST_F(SnapshotFixture, writes_and_reopens_complete_deterministic_module_state) EXPECT_EQ(archive.keys().size(), 3); } +TEST_F(SnapshotFixture, rewrites_perception_shadow_ids_from_detached_namespace) { + const auto detachedContext = + SerializedIdentityContext::detachedRecord("availnpc0.utc"); + auto target = game.newCreature(); + target->assignSerializedObjectIdentity({detachedContext, 77u}); + TestGameModule::addSnapshotObject(*area, target); + + auto encounterRecord = Gff::Builder() + .type(7) + .field(Gff::Field::newList( + "PerceptionList", + {Gff::Builder() + .type(0) + .field(Gff::Field::newDword( + "ObjectId", 77u)) + .field(Gff::Field::newByte( + "PerceptionData", 3u)) + .build()})) + .build(); + auto encounter = game.newEncounter(); + encounter->deserializeRuntimeState( + *encounterRecord, detachedContext); + encounter->captureSaveRecord( + *encounterRecord, + detachedContext, + {SaveRecordOriginKind::ActiveGitObject, "detached-test"}); + TestGameModule::addSnapshotObject(*area, encounter); + game.resolveSavedObjectReferences(); + ASSERT_EQ(encounter->savedReference("Perception/0"), target); + + auto result = ModuleSnapshotBuilder(game, "module003").build(); + ASSERT_TRUE(result) << result.message; + auto git = readGff(result.snapshot->gitBytes); + auto targetRecord = recordById(*git, "Creature List", target->id()); + ASSERT_TRUE(targetRecord); + auto savedEncounter = recordById( + *git, "Encounter List", encounter->id()); + ASSERT_TRUE(savedEncounter); + ASSERT_EQ(savedEncounter->getList("PerceptionList").size(), 1u); + const auto rewritten = savedEncounter->getList("PerceptionList") + .front() + ->getUint("ObjectId"); + EXPECT_EQ(rewritten, targetRecord->getUint("ObjectId")); + EXPECT_NE(rewritten, 77u); +} + TEST_F(SnapshotFixture, authoritative_membership_omits_deleted_shadow_records) { auto door = addDoorWithShadow(); auto present = ModuleSnapshotBuilder(game, "module004").build(); @@ -456,6 +666,44 @@ TEST_F(SnapshotFixture, authoritative_membership_omits_deleted_shadow_records) { EXPECT_TRUE(deleted.snapshot->git->getList("Door List").empty()); } +TEST_F(SnapshotFixture, writes_creature_vitality_on_the_retail_base_axis) { + NiceMock strings; + NiceMock twoDas; + Classes classes(strings, twoDas); + auto soldier = std::make_shared( + ClassType::Soldier, classes, strings, twoDas); + + auto creature = game.newCreature(); + creature->attributes().addClassLevels(soldier.get(), 3); + creature->attributes().setAbilityScore(Ability::Constitution, 12); + creature->attributes().addFeat(FeatType::Toughness); + creature->setMaxHitPoints(30); + creature->setCurrentHitPoints(32); + auto staleShadow = Gff::Builder() + .field(Gff::Field::newDword( + "ObjectId", creature->id())) + .field(Gff::Field::newShort("HitPoints", 99)) + .field(Gff::Field::newShort("MaxHitPoints", 99)) + .field(Gff::Field::newShort( + "CurrentHitPoints", 99)) + .build(); + creature->captureSaveRecord( + *staleShadow, + SerializedIdentityContext::moduleGraph("tat_m17ab"), + {SaveRecordOriginKind::ActiveGitObject, "tat_m17ab"}); + TestGameModule::addSnapshotObject(*area, creature); + + auto saved = ModuleSnapshotBuilder(game, "tat_m17ab").build(); + + ASSERT_TRUE(saved) << saved.message; + auto record = recordById( + *saved.snapshot->git, "Creature List", creature->id()); + ASSERT_TRUE(record); + EXPECT_EQ(30, record->getInt("HitPoints")); + EXPECT_EQ(36, record->getInt("MaxHitPoints")); + EXPECT_EQ(26, record->getInt("CurrentHitPoints")); +} + TEST_F(SnapshotFixture, linked_door_helpers_are_derived_not_saved_git_members) { auto door = game.newDoor(); door->setTag("linked_door"); @@ -524,7 +772,10 @@ TEST_F(SnapshotFixture, module_item_ids_are_global_deterministic_and_retained) { SaveRecordOriginKind kind, const std::string &owner) { auto record = Gff::Builder().type(0) .field(Gff::Field::newDword("ObjectId", id)).build(); - item->captureOwnerLocalSaveRecord(*record, {kind, owner}); + item->captureSaveRecord( + *record, + SerializedIdentityContext::moduleGraph("module005"), + {kind, owner}); }; retain(first, 70, SaveRecordOriginKind::PlaceableItem, "a"); retain(third, 71, SaveRecordOriginKind::StoreItem, "b"); @@ -608,7 +859,15 @@ struct PartyItemIdFixture : TestWithParam { SaveRecordOriginKind kind, const std::string &owner) { auto record = Gff::Builder().type(0) .field(Gff::Field::newDword("ObjectId", id)).build(); - item->captureOwnerLocalSaveRecord(*record, {kind, owner}); + const auto identityContext = + kind == SaveRecordOriginKind::PlaceableItem || + kind == SaveRecordOriginKind::StoreItem + ? SerializedIdentityContext::moduleGraph("module_pid") + : SerializedIdentityContext::detachedRecord(owner); + item->captureSaveRecord( + *record, + identityContext, + {kind, owner}); } std::shared_ptr worldSoundWithId(uint32_t id) { @@ -667,6 +926,71 @@ TEST_P(PartyItemIdFixture, foreign_party_item_id_does_not_collide_with_a_world_o EXPECT_GE(ids.front(), 2u); } +TEST_F(SnapshotFixture, translates_authoritative_and_detached_item_136_by_object) { + auto placeable = game.newPlaceable(); + auto authoritative = game.newOwnedItem(); + auto detached = game.newOwnedItem(); + auto item136 = Gff::Builder().type(0) + .field(Gff::Field::newDword("ObjectId", 136)).build(); + authoritative->captureSaveRecord( + *item136, + SerializedIdentityContext::moduleGraph("tat_m17ab"), + {SaveRecordOriginKind::PlaceableItem, "module-chest"}); + detached->captureSaveRecord( + *item136, + SerializedIdentityContext::detachedRecord("availnpc7.utc"), + {SaveRecordOriginKind::EquippedItem, "availnpc7.utc"}); + placeable->addItem(authoritative); + TestGameModule::addSnapshotObject(*area, placeable); + TestGameModule::setSnapshotEquipment( + *player, InventorySlots::rightWeapon, detached); + + auto saved = ModuleSnapshotBuilder(game, "tat_m17ab").build(); + + ASSERT_TRUE(saved) << saved.message; + auto placeables = saved.snapshot->git->getList("Placeable List"); + ASSERT_EQ(placeables.size(), 1u); + auto moduleItems = placeables.front()->getList("ItemList"); + ASSERT_EQ(moduleItems.size(), 1u); + EXPECT_EQ(moduleItems.front()->getUint("ObjectId"), 136u); + auto playerRecord = saved.snapshot->ifo->getList("Mod_PlayerList").front(); + auto equipment = playerRecord->getList("Equip_ItemList"); + ASSERT_EQ(equipment.size(), 1u); + EXPECT_NE(equipment.front()->getUint("ObjectId"), 136u); + EXPECT_NE(equipment.front()->getUint("ObjectId"), + moduleItems.front()->getUint("ObjectId")); +} + +TEST_F(SnapshotFixture, imports_overlapping_ids_from_two_detached_graphs_once_each) { + auto first = game.newOwnedItem(); + auto second = game.newOwnedItem(); + auto item136 = Gff::Builder().type(0) + .field(Gff::Field::newDword("ObjectId", 136)).build(); + first->captureSaveRecord( + *item136, + SerializedIdentityContext::detachedRecord("availnpc0.utc"), + {SaveRecordOriginKind::EquippedItem, "availnpc0.utc"}); + second->captureSaveRecord( + *item136, + SerializedIdentityContext::detachedRecord("availnpc7.utc"), + {SaveRecordOriginKind::EquippedItem, "availnpc7.utc"}); + TestGameModule::setSnapshotEquipment( + *player, InventorySlots::rightWeapon, first); + TestGameModule::setSnapshotEquipment( + *player, InventorySlots::leftWeapon, second); + + auto saved = ModuleSnapshotBuilder(game, "tat_m17ab").build(); + + ASSERT_TRUE(saved) << saved.message; + auto equipment = saved.snapshot->ifo->getList("Mod_PlayerList") + .front()->getList("Equip_ItemList"); + ASSERT_EQ(equipment.size(), 2u); + EXPECT_NE(equipment[0]->getUint("ObjectId"), + equipment[1]->getUint("ObjectId")); + EXPECT_NE(equipment[0]->getUint("ObjectId"), 136u); + EXPECT_NE(equipment[1]->getUint("ObjectId"), 136u); +} + // B: reallocation happens even when nothing collides. TEST_P(PartyItemIdFixture, party_item_ids_are_allocated_not_retained) { auto equipped = game.newOwnedItem(); @@ -782,8 +1106,10 @@ struct CameraIdFixture : TestWithParam { item->setTag(tag); auto record = Gff::Builder().type(0) .field(Gff::Field::newDword("ObjectId", id)).build(); - item->captureOwnerLocalSaveRecord( - *record, {SaveRecordOriginKind::PlaceableItem, "module_cam"}); + item->captureSaveRecord( + *record, + SerializedIdentityContext::moduleGraph("module_cam"), + {SaveRecordOriginKind::PlaceableItem, "module_cam"}); placeable->addItem(item); TestGameModule::addSnapshotObject(*area, placeable); return item; @@ -895,9 +1221,13 @@ TEST_P(CameraIdFixture, genuine_saved_object_collision_is_still_rejected) { cameraWithRuntimeId(577, 3); auto soundA = game.newSound(); TestGameModule::setSnapshotObjectId(*soundA, 321); + soundA->assignSerializedObjectIdentity({ + SerializedIdentityContext::moduleGraph("module_cam"), 321}); TestGameModule::addSnapshotObject(*area, soundA); auto soundB = game.newSound(); TestGameModule::setSnapshotObjectId(*soundB, 321); + soundB->assignSerializedObjectIdentity({ + SerializedIdentityContext::moduleGraph("module_cam"), 321}); TestGameModule::addSnapshotObject(*area, soundB); auto result = ModuleSnapshotBuilder(game, "module_cam").build(); @@ -914,8 +1244,10 @@ TEST_P(CameraIdFixture, foreign_item_id_is_still_allocated_not_retained) { equipped->setTag("blaster"); auto record = Gff::Builder().type(0) .field(Gff::Field::newDword("ObjectId", 577)).build(); - equipped->captureOwnerLocalSaveRecord( - *record, {SaveRecordOriginKind::EquippedItem, "some_other_module"}); + equipped->captureSaveRecord( + *record, + SerializedIdentityContext::detachedRecord("some_other_module"), + {SaveRecordOriginKind::EquippedItem, "some_other_module"}); TestGameModule::setSnapshotEquipment(*player, InventorySlots::rightWeapon, equipped); auto result = ModuleSnapshotBuilder(game, "module_cam").build(); @@ -951,8 +1283,12 @@ TEST_F(SnapshotFixture, retained_module_item_collision_is_rejected) { auto item = game.newOwnedItem(); auto record = Gff::Builder().type(0) .field(Gff::Field::newDword("ObjectId", owner->id())).build(); - item->captureOwnerLocalSaveRecord( - *record, {SaveRecordOriginKind::PlaceableItem, "owner"}); + item->captureSaveRecord( + *record, + SerializedIdentityContext::moduleGraph("module005"), + {SaveRecordOriginKind::PlaceableItem, "owner"}); + owner->assignSerializedObjectIdentity({ + SerializedIdentityContext::moduleGraph("module005"), owner->id()}); owner->addItem(item); TestGameModule::addSnapshotObject(*area, owner); @@ -960,7 +1296,8 @@ TEST_F(SnapshotFixture, retained_module_item_collision_is_rejected) { EXPECT_FALSE(saved); EXPECT_EQ(saved.error, ModuleSnapshotError::UnsupportedLiveState); - EXPECT_THAT(saved.message, HasSubstr("retained module item ID collides")); + EXPECT_THAT(saved.message, HasSubstr( + "authoritative saved object ID collides")); } TEST(ModuleSnapshot, k1_reserved_player_id_does_not_drive_cursor_or_duplicate_inventory) { @@ -991,7 +1328,9 @@ TEST(ModuleSnapshot, k1_reserved_player_id_does_not_drive_cursor_or_duplicate_in EXPECT_EQ(saved.snapshot->ifo->getUint("Mod_NextObjId0"), 136u); Game reloaded(GameID::KotOR, "", engine.options(), engine.services(), console); - reloaded.prepareSavedRuntimeNamespace(*saved.snapshot->ifo); + reloaded.prepareSavedRuntimeNamespace( + *saved.snapshot->ifo, + SerializedIdentityContext::moduleGraph("test-module")); auto postLoadItem = reloaded.newItem(); EXPECT_EQ(postLoadItem->id(), 136u); EXPECT_EQ(reloaded.getObjectById(136), postLoadItem); @@ -1036,8 +1375,12 @@ TEST_F(SnapshotFixture, reserved_limbo_party_id_does_not_drive_ordinary_cursor) TEST_F(SnapshotFixture, duplicate_reserved_party_ids_are_rejected) { TestGameModule::setSnapshotObjectId(*player, 0x7fffffffu); + player->assignSerializedObjectIdentity({ + SerializedIdentityContext::moduleGraph("module006"), 0x7fffffffu}); auto companion = game.newCreature(); TestGameModule::setSnapshotObjectId(*companion, 0x7fffffffu); + companion->assignSerializedObjectIdentity({ + SerializedIdentityContext::moduleGraph("module006"), 0x7fffffffu}); TestGameModule::addSnapshotLimboCreature(*game.module(), companion); auto saved = ModuleSnapshotBuilder(game, "module006").build(); @@ -1064,11 +1407,11 @@ TEST_F(SnapshotFixture, module_player_keeps_equipment_but_not_shared_inventory) EXPECT_LT(modulePlayer->getList("Equip_ItemList").front()->getUint("ObjectId"), kSavedRuntimeInvalidObjectId); } -TEST_F(SnapshotFixture, preserves_open2_and_accepts_retail_world_ids_zero_and_one) { +TEST_F(SnapshotFixture, preserves_open2_and_accepts_low_retail_world_ids) { auto door = game.newDoor(); auto placeable = game.newPlaceable(); - TestGameModule::setSnapshotObjectId(*door, 0); - TestGameModule::setSnapshotObjectId(*placeable, 1); + TestGameModule::setSnapshotObjectId(*door, 1); + TestGameModule::setSnapshotObjectId(*placeable, 4); TestGameModule::setSnapshotDoorState(*door, DoorState::Opened2); TestGameModule::addSnapshotObject(*area, door); TestGameModule::addSnapshotObject(*area, placeable); @@ -1076,10 +1419,10 @@ TEST_F(SnapshotFixture, preserves_open2_and_accepts_retail_world_ids_zero_and_on auto saved = ModuleSnapshotBuilder(game, "module007").build(); ASSERT_TRUE(saved) << saved.message; - auto doorRecord = recordById(*saved.snapshot->git, "Door List", 0); + auto doorRecord = recordById(*saved.snapshot->git, "Door List", 1); ASSERT_TRUE(doorRecord); EXPECT_EQ(doorRecord->getUint("OpenState"), 2u); - EXPECT_TRUE(recordById(*saved.snapshot->git, "Placeable List", 1)); + EXPECT_TRUE(recordById(*saved.snapshot->git, "Placeable List", 4)); } TEST_F(SnapshotFixture, rejects_duplicate_authoritative_world_ids) { @@ -1087,6 +1430,10 @@ TEST_F(SnapshotFixture, rejects_duplicate_authoritative_world_ids) { auto second = game.newPlaceable(); TestGameModule::setSnapshotObjectId(*first, 77); TestGameModule::setSnapshotObjectId(*second, 77); + first->assignSerializedObjectIdentity({ + SerializedIdentityContext::moduleGraph("module008"), 77}); + second->assignSerializedObjectIdentity({ + SerializedIdentityContext::moduleGraph("module008"), 77}); TestGameModule::addSnapshotObject(*area, first); TestGameModule::addSnapshotObject(*area, second); @@ -1100,6 +1447,10 @@ TEST_F(SnapshotFixture, rejects_duplicate_authoritative_world_ids) { TEST_F(SnapshotFixture, rejects_world_id_collision_with_structural_area) { auto door = game.newDoor(); TestGameModule::setSnapshotObjectId(*door, area->id()); + area->assignSerializedObjectIdentity({ + SerializedIdentityContext::moduleGraph("module008"), area->id()}); + door->assignSerializedObjectIdentity({ + SerializedIdentityContext::moduleGraph("module008"), area->id()}); TestGameModule::addSnapshotObject(*area, door); auto saved = ModuleSnapshotBuilder(game, "module008").build(); @@ -1118,8 +1469,12 @@ TEST_F(SnapshotFixture, deleted_reference_targets_are_written_as_retail_invalid) .field(Gff::Field::newDword("ObjectId", trigger->id())) .field(Gff::Field::newDword("CreatorId", door->id())).build(); trigger->captureSaveRecord( - *source, {SaveRecordOriginKind::ActiveGitObject, "tat_m17ab"}); - TestGameModule::deserializeSnapshotRuntimeState(*trigger, *source); + *source, + SerializedIdentityContext::moduleGraph("tat_m17ab"), + {SaveRecordOriginKind::ActiveGitObject, "tat_m17ab"}); + TestGameModule::deserializeSnapshotRuntimeState( + *trigger, *source, + SerializedIdentityContext::moduleGraph("tat_m17ab")); game.resolveSavedObjectReferences(); EffectInstance effect; @@ -1181,11 +1536,13 @@ TEST_F(SnapshotFixture, play_animation_is_a_supported_transition_snapshot_action auto result = ModuleSnapshotBuilder(game, "module003").build(); ASSERT_TRUE(result) << result.message; + EXPECT_EQ(result.snapshot->git->getUint("UseTemplates"), 0u); auto ifo = readGff(result.snapshot->ifoBytes); auto playerRecord = ifo->getList("Mod_PlayerList").front(); ASSERT_EQ(playerRecord->getList("ActionList").size(), 1); auto saved = SavedActionRecord::fromGff( - *playerRecord->getList("ActionList").front()); + *playerRecord->getList("ActionList").front(), + snapshotIdentityContext()); EXPECT_EQ(saved.actionId, 6u); EXPECT_EQ(saved.groupActionId, 19); ASSERT_EQ(saved.parameters.size(), 5); @@ -1211,13 +1568,18 @@ TEST_F(SnapshotFixture, attack_object_is_a_supported_transition_snapshot_action) auto playerRecord = ifo->getList("Mod_PlayerList").front(); ASSERT_EQ(playerRecord->getList("ActionList").size(), 1); auto saved = SavedActionRecord::fromGff( - *playerRecord->getList("ActionList").front()); + *playerRecord->getList("ActionList").front(), + snapshotIdentityContext()); EXPECT_EQ(saved.actionId, 12u); EXPECT_EQ(saved.groupActionId, 29); EXPECT_EQ(saved.declaredParameterCount, 10); ASSERT_EQ(saved.parameters.size(), 10); EXPECT_EQ(std::get(saved.parameters[1].payload).id, target->id()); + game.registerSavedObjectIdentity( + std::get(saved.parameters[1].payload).id, + target, + SerializedIdentityContext::moduleGraph("module003")); ASSERT_TRUE(saved.bindObjectReferences(game)); EXPECT_TRUE(saved.toRuntimeAction(game)); } @@ -1240,12 +1602,17 @@ TEST_F(SnapshotFixture, move_to_location_is_a_supported_transition_snapshot_acti auto playerRecord = ifo->getList("Mod_PlayerList").front(); ASSERT_EQ(playerRecord->getList("ActionList").size(), 1); auto saved = SavedActionRecord::fromGff( - *playerRecord->getList("ActionList").front()); + *playerRecord->getList("ActionList").front(), + snapshotIdentityContext()); EXPECT_EQ(saved.actionId, 1u); EXPECT_EQ(saved.groupActionId, 33); EXPECT_EQ(saved.declaredParameterCount, 13); ASSERT_EQ(saved.parameters.size(), 13); EXPECT_TRUE(std::get(saved.parameters[4].payload).isInvalid()); + game.registerSavedObjectIdentity( + std::get(saved.parameters[3].payload).id, + area, + SerializedIdentityContext::moduleGraph("module003")); ASSERT_TRUE(saved.bindObjectReferences(game)); EXPECT_TRUE(std::dynamic_pointer_cast( saved.toRuntimeAction(game))); @@ -1266,7 +1633,8 @@ TEST_F(SnapshotFixture, move_to_object_is_a_supported_transition_snapshot_action auto playerRecord = ifo->getList("Mod_PlayerList").front(); ASSERT_EQ(playerRecord->getList("ActionList").size(), 1); auto saved = SavedActionRecord::fromGff( - *playerRecord->getList("ActionList").front()); + *playerRecord->getList("ActionList").front(), + snapshotIdentityContext()); EXPECT_EQ(saved.actionId, 17u); EXPECT_EQ(saved.groupActionId, 13); ASSERT_EQ(saved.parameters.size(), 5); @@ -1290,7 +1658,8 @@ TEST_F(SnapshotFixture, forced_move_to_object_is_a_supported_transition_snapshot ASSERT_TRUE(result) << result.message; auto ifo = readGff(result.snapshot->ifoBytes); auto saved = SavedActionRecord::fromGff( - *ifo->getList("Mod_PlayerList").front()->getList("ActionList").front()); + *ifo->getList("Mod_PlayerList").front()->getList("ActionList").front(), + snapshotIdentityContext()); EXPECT_EQ(saved.actionId, 1u); EXPECT_EQ(saved.groupActionId, 13); ASSERT_EQ(saved.parameters.size(), 13); @@ -1301,6 +1670,14 @@ TEST_F(SnapshotFixture, forced_move_to_object_is_a_supported_transition_snapshot } TEST_F(SnapshotFixture, pending_do_command_is_a_supported_transition_snapshot_action) { + auto talentItem = game.newOwnedItem(); + ASSERT_NE(talentItem->id(), 200u); + talentItem->assignSerializedObjectIdentity( + {snapshotIdentityContext(), 200u}); + game.registerSavedObjectIdentity( + 200u, talentItem, snapshotIdentityContext()); + TestGameModule::setSnapshotEquipment(*player, 0, talentItem); + auto program = std::make_shared("transition_command"); program->add(script::Instruction(script::InstructionType::RETN)); auto state = std::make_shared(); @@ -1309,7 +1686,7 @@ TEST_F(SnapshotFixture, pending_do_command_is_a_supported_transition_snapshot_ac state->globals = { script::Variable::ofInt(9), script::Variable::ofTalent(std::make_shared( - TalentType::Spell, 123, 2, kSavedRuntimeInvalidObjectId, 5, 14, 1))}; + TalentType::Spell, 123, 2, talentItem->id(), 5, 14, 1))}; state->locals = {script::Variable::ofString("after-talent")}; auto context = std::make_shared(); context->savedState = std::move(state); @@ -1341,14 +1718,15 @@ TEST_F(SnapshotFixture, pending_do_command_is_a_supported_transition_snapshot_ac EXPECT_EQ(fieldType("Type"), Gff::FieldType::Dword); EXPECT_EQ(fieldType("ItemPropertyInde"), Gff::FieldType::Dword); - auto saved = SavedActionRecord::fromGff(*actionRecord); + auto saved = SavedActionRecord::fromGff( + *actionRecord, snapshotIdentityContext()); EXPECT_EQ(saved.actionId, 37u); EXPECT_EQ(saved.groupActionId, 18); EXPECT_EQ(saved.declaredParameterCount, 1); ASSERT_EQ(saved.parameters.size(), 1); EXPECT_EQ(saved.parameters[0].type, static_cast(SavedActionParameterType::ScriptSituation)); - const auto &situation = std::get(saved.parameters[0].payload); + auto &situation = std::get(saved.parameters[0].payload); EXPECT_EQ(situation.basePointer, 2); EXPECT_EQ(situation.stackPointer, 3); ASSERT_EQ(situation.stack.size(), 3); @@ -1357,11 +1735,86 @@ TEST_F(SnapshotFixture, pending_do_command_is_a_supported_transition_snapshot_ac EXPECT_EQ(talent.id, 123); EXPECT_EQ(talent.type, static_cast(TalentType::Spell)); EXPECT_EQ(talent.multiClass, 2); - EXPECT_EQ(talent.item.id, kSavedRuntimeInvalidObjectId); + EXPECT_EQ(talent.item.id, 200u); + EXPECT_NE(talent.item.id, talentItem->id()); EXPECT_EQ(talent.itemPropertyIndex, 5); EXPECT_EQ(talent.casterLevel, 14); EXPECT_EQ(talent.metaType, 1); EXPECT_EQ(std::get(situation.stack[2].payload), "after-talent"); + ASSERT_TRUE(situation.bindObjectReferences(game)); + EXPECT_EQ(talent.item.boundObject(), talentItem); +} + +TEST_F(SnapshotFixture, live_effect_continuation_translates_creator_and_all_object_slots) { + auto creator = game.newCreature(); + TestGameModule::addSnapshotObject(*area, creator); + std::array, 4> targets; + for (auto &target : targets) { + target = game.newCreature(); + TestGameModule::addSnapshotObject( + *area, std::static_pointer_cast(target)); + } + + creator->assignSerializedObjectIdentity( + {snapshotIdentityContext(), 300u}); + game.registerSavedObjectIdentity( + 300u, creator, snapshotIdentityContext()); + for (size_t index = 0; index < targets.size(); ++index) { + const uint32_t savedId = 301u + static_cast(index); + targets[index]->assignSerializedObjectIdentity( + {snapshotIdentityContext(), savedId}); + game.registerSavedObjectIdentity( + savedId, targets[index], snapshotIdentityContext()); + ASSERT_NE(targets[index]->id(), savedId); + } + + auto program = std::make_shared("effect_command"); + program->add(script::Instruction(script::InstructionType::RETN)); + auto state = std::make_shared(); + state->program = std::move(program); + state->insOffset = 13; + state->globals = {script::Variable::ofEffect( + std::make_shared(creator, targets))}; + auto context = std::make_shared(); + context->savedState = std::move(state); + auto action = game.newAction(std::move(context)); + player->addAction(action); + + auto result = ModuleSnapshotBuilder(game, "module003").build(); + ASSERT_TRUE(result) << result.message; + auto ifo = readGff(result.snapshot->ifoBytes); + auto actionRecord = ifo->getList("Mod_PlayerList") + .front() + ->getList("ActionList") + .front(); + auto saved = SavedActionRecord::fromGff( + *actionRecord, snapshotIdentityContext()); + auto &situation = std::get( + saved.parameters.front().payload); + auto &effect = std::get(situation.stack.front().payload); + EXPECT_EQ(effect.creatorId, 300u); + EXPECT_EQ( + effect.objectParameters, + (std::array {301u, 302u, 303u, 304u})); + + ASSERT_TRUE(situation.bindObjectReferences(game)); + EXPECT_EQ(effect.boundCreator(), creator); + for (size_t index = 0; index < targets.size(); ++index) { + EXPECT_EQ(effect.boundObjectParameter(index), targets[index]); + } + auto imported = SavedScriptSituationImporter( + game, engine.resourceModule().scripts()) + .import(situation); + ASSERT_TRUE(imported) << imported.message; + auto runtimeEffect = std::dynamic_pointer_cast( + imported.continuation->executionState().globals.front().engineType); + ASSERT_TRUE(runtimeEffect); + EXPECT_EQ(runtimeEffect->instance().creatorId, creator->id()); + for (size_t index = 0; index < targets.size(); ++index) { + EXPECT_EQ( + runtimeEffect->instance().objectParameters[index], + targets[index]->id()); + } } TEST_F(SnapshotFixture, runtime_delays_export_as_retail_timed_events_with_remaining_game_time) { @@ -1387,7 +1840,8 @@ TEST_F(SnapshotFixture, runtime_delays_export_as_retail_timed_events_with_remain ASSERT_TRUE(second) << second.message; EXPECT_EQ(first.snapshot->ifoBytes, second.snapshot->ifoBytes); auto ifo = readGff(first.snapshot->ifoBytes); - auto queue = SavedEventQueue::fromGff(*ifo); + auto queue = SavedEventQueue::fromGff( + *ifo, snapshotIdentityContext()); ASSERT_EQ(queue.events.size(), 1); const auto &event = queue.events.front(); EXPECT_EQ(event.eventId, static_cast(SavedEventType::Timed)); @@ -1402,6 +1856,145 @@ TEST_F(SnapshotFixture, runtime_delays_export_as_retail_timed_events_with_remain EXPECT_EQ(std::get(situation->stack.front().payload), 9); } +TEST_F(SnapshotFixture, structural_module_references_restore_onto_a_different_runtime_id) { + const auto context = + SerializedIdentityContext::moduleGraph("tat_m17ab"); + SavedEventRecord event; + event.day = 8; + event.time = 9123; + event.object = SavedObjectReference::fromRuntimeId(game.module()->id()); + event.caller = SavedObjectReference::fromRuntimeId(game.module()->id()); + event.eventId = static_cast(SavedEventType::CloseObject); + game.module()->enqueueSaveEvent(event); + + auto saved = ModuleSnapshotBuilder(game, "tat_m17ab").build(); + + ASSERT_TRUE(saved) << saved.message; + uint32_t privateModuleId = 0; + EXPECT_FALSE(saved.snapshot->ifo->readDword( + privateModuleId, "ReoneModObjId")); + auto serialized = SavedEventQueue::fromGff( + *saved.snapshot->ifo, snapshotIdentityContext()); + ASSERT_EQ(serialized.events.size(), 1u); + EXPECT_EQ( + serialized.events.front().object.id, + kSavedRuntimeModuleObjectId); + EXPECT_EQ( + serialized.events.front().caller.id, + kSavedRuntimeModuleObjectId); + + Game restored( + GameID::KotOR, "", engine.options(), engine.services(), console); + restored.prepareSavedRuntimeNamespace(*saved.snapshot->ifo, context); + auto restoredModule = restored.newSavedModule(); + ASSERT_NE(restoredModule->id(), kSavedRuntimeModuleObjectId); + TestGameModule::registerSavedModuleReferenceTarget( + restored, restoredModule, context); + restoredModule->deserializeSavedEventQueue( + *saved.snapshot->ifo, context); + restoredModule->bindSavedEventQueue(); + const auto &events = restoredModule->savedEventQueue().events; + ASSERT_EQ(events.size(), 1u); + EXPECT_EQ(events.front().object.boundObject(), restoredModule); + EXPECT_EQ(events.front().caller.boundObject(), restoredModule); +} + +TEST_F(SnapshotFixture, recovered_event_payload_references_renumber_symmetrically) { + const auto sourceContext = + SerializedIdentityContext::moduleGraph("source-module"); + game.registerSavedObjectIdentity(390u, player, sourceContext); + std::vector> targets; + for (uint32_t index = 0; index < 5; ++index) { + auto target = game.newCreature(); + target->setTag("event_ref_" + std::to_string(index)); + TestGameModule::addSnapshotObject(*area, target); + game.registerSavedObjectIdentity( + 400u + index, target, sourceContext); + targets.push_back(std::move(target)); + } + + auto aoo = Gff::Builder().type(0x3333) + .field(Gff::Field::newDword("Value", 400u)).build(); + auto combat = Gff::Builder().type(0x2222) + .field(Gff::Field::newDword("ReactObject", 401u)) + .field(Gff::Field::newDword("AmmoItem", 402u)) + .field(Gff::Field::newInt("AttackType", 17)).build(); + auto feedbackObject1 = Gff::Builder().type(0xbaad) + .field(Gff::Field::newDword( + "ObjectValue", 403u)).build(); + auto feedbackObject2 = Gff::Builder().type(0xbaad) + .field(Gff::Field::newDword( + "ObjectValue", 404u)).build(); + auto feedback = Gff::Builder().type(0xcccc) + .field(Gff::Field::newByte("Type", 9)) + .field(Gff::Field::newList( + "ObjectIDList", + {feedbackObject1, feedbackObject2})).build(); + auto spell = Gff::Builder().type(0x6666) + .field(Gff::Field::newDword("CasterId", 400u)) + .field(Gff::Field::newDword("TargetId", 401u)) + .field(Gff::Field::newDword("AreaId", 402u)) + .field(Gff::Field::newDword("ItemId", 403u)).build(); + auto makeEvent = [&](uint32_t type, std::shared_ptr data) { + return Gff::Builder().type(0xabcd) + .field(Gff::Field::newDword("Day", 1)) + .field(Gff::Field::newDword("Time", type)) + .field(Gff::Field::newDword("ObjectId", 390u)) + .field(Gff::Field::newDword("CallerId", 390u)) + .field(Gff::Field::newDword("EventId", type)) + .field(Gff::Field::newStruct("EventData", std::move(data))) + .build(); + }; + for (const auto &[type, data] : + std::vector>> { + {20u, aoo}, {15u, combat}, {22u, feedback}, {19u, spell}}) { + auto event = SavedEventRecord::fromGff( + *makeEvent(type, data), sourceContext); + ASSERT_TRUE(event.bindObjectReferences(game)); + game.module()->enqueueSaveEvent(std::move(event)); + } + + auto saved = ModuleSnapshotBuilder(game, "module003").build(); + + ASSERT_TRUE(saved) << saved.message; + std::map idByTag; + for (const auto &record : saved.snapshot->git->getList("Creature List")) { + idByTag.emplace( + record->getString("Tag"), record->getUint("ObjectId")); + } + ASSERT_EQ(idByTag.size(), targets.size()); + for (uint32_t index = 0; index < targets.size(); ++index) { + EXPECT_NE(idByTag.at("event_ref_" + std::to_string(index)), + 400u + index); + } + std::map> eventByType; + for (const auto &record : saved.snapshot->ifo->getList("EventQueue")) { + eventByType.emplace(record->getUint("EventId"), record); + } + ASSERT_EQ(eventByType.size(), 4u); + EXPECT_EQ( + eventByType.at(20u)->findStruct("EventData")->getUint("Value"), + idByTag.at("event_ref_0")); + const auto savedCombat = eventByType.at(15u)->findStruct("EventData"); + EXPECT_EQ(savedCombat->getUint("ReactObject"), + idByTag.at("event_ref_1")); + EXPECT_EQ(savedCombat->getUint("AmmoItem"), + idByTag.at("event_ref_2")); + EXPECT_EQ(savedCombat->getInt("AttackType"), 17); + const auto savedFeedback = eventByType.at(22u)->findStruct("EventData"); + ASSERT_EQ(savedFeedback->getList("ObjectIDList").size(), 2u); + EXPECT_EQ(savedFeedback->getList("ObjectIDList")[0]->getUint("ObjectValue"), + idByTag.at("event_ref_3")); + EXPECT_EQ(savedFeedback->getList("ObjectIDList")[1]->getUint("ObjectValue"), + idByTag.at("event_ref_4")); + EXPECT_EQ(savedFeedback->getUint("Type"), 9u); + const auto savedSpell = eventByType.at(19u)->findStruct("EventData"); + EXPECT_EQ(savedSpell->getUint("CasterId"), idByTag.at("event_ref_0")); + EXPECT_EQ(savedSpell->getUint("TargetId"), idByTag.at("event_ref_1")); + EXPECT_EQ(savedSpell->getUint("AreaId"), idByTag.at("event_ref_2")); + EXPECT_EQ(savedSpell->getUint("ItemId"), idByTag.at("event_ref_3")); +} + TEST_F(SnapshotFixture, runtime_delays_preserve_stable_time_order_and_fail_closed) { // One real second before the end of a game day. At Mod_MinPerHour=10 a day // is 10 * 60 * 1000 * 24 ms, so the 2 s and 4 s delays cross midnight. @@ -1425,7 +2018,8 @@ TEST_F(SnapshotFixture, runtime_delays_preserve_stable_time_order_and_fail_close auto result = ModuleSnapshotBuilder(game, "module003").build(); ASSERT_TRUE(result) << result.message; - auto queue = SavedEventQueue::fromGff(*readGff(result.snapshot->ifoBytes)); + auto queue = SavedEventQueue::fromGff( + *readGff(result.snapshot->ifoBytes), snapshotIdentityContext()); ASSERT_EQ(queue.events.size(), 3); EXPECT_EQ(queue.events[0].day, 3u); EXPECT_EQ(queue.events[0].time, 1000u); @@ -1462,7 +2056,15 @@ TEST_F(SnapshotFixture, due_delay_is_inert_on_restore_and_delivered_exactly_once auto ifo = readGff(saved.snapshot->ifoBytes); TestGameModule::clearSnapshotDelayed(*player); - game.module()->deserializeSavedEventQueue(*ifo); + const auto serialized = SavedEventQueue::fromGff( + *ifo, snapshotIdentityContext()); + ASSERT_EQ(serialized.events.size(), 1u); + game.registerSavedObjectIdentity( + serialized.events.front().object.id, + player, + snapshotIdentityContext()); + game.module()->deserializeSavedEventQueue( + *ifo, snapshotIdentityContext()); game.module()->bindSavedEventQueue(); game.module()->publishSavedEventQueue(); @@ -1473,7 +2075,7 @@ TEST_F(SnapshotFixture, due_delay_is_inert_on_restore_and_delivered_exactly_once EXPECT_EQ(game.module()->pendingSavedEventCount(), 0u); } -TEST_F(SnapshotFixture, writes_a_normalized_calendar_pair_split_from_the_absolute_clock) { +TEST_F(SnapshotFixture, writes_the_normalized_retail_pause_pair) { // The runtime clock is absolute milliseconds; the IFO stores a day and a // time of day. The split happens here, at the serialization boundary, so // every record written is normalized regardless of where the clock stands. @@ -1488,13 +2090,15 @@ TEST_F(SnapshotFixture, writes_a_normalized_calendar_pair_split_from_the_absolut auto ifo = readGff(result.snapshot->ifoBytes); EXPECT_EQ(ifo->getUint("Mod_MinPerHour"), kMinutesPerHour); - EXPECT_EQ(ifo->getUint("Mod_CalendarDay"), 7u); - EXPECT_EQ(ifo->getUint("Mod_TimeOfDay"), 2000u); - EXPECT_LT(ifo->getUint("Mod_TimeOfDay"), millisecondsPerDay); + EXPECT_EQ(ifo->getUint("Mod_PauseDay"), 7u); + EXPECT_EQ(ifo->getUint("Mod_PauseTime"), 2000u); + EXPECT_LT(ifo->getUint("Mod_PauseTime"), millisecondsPerDay); + EXPECT_FALSE(ifo->has("Mod_CalendarDay")); + EXPECT_FALSE(ifo->has("Mod_TimeOfDay")); // And the pair recomposes to exactly the clock that produced it. - EXPECT_EQ(static_cast(ifo->getUint("Mod_CalendarDay")) * + EXPECT_EQ(static_cast(ifo->getUint("Mod_PauseDay")) * millisecondsPerDay + - ifo->getUint("Mod_TimeOfDay"), + ifo->getUint("Mod_PauseTime"), game.worldTimeMilliseconds()); } @@ -1512,7 +2116,8 @@ TEST_F(SnapshotFixture, pending_start_conversation_is_a_supported_transition_sna ASSERT_TRUE(result) << result.message; auto ifo = readGff(result.snapshot->ifoBytes); auto saved = SavedActionRecord::fromGff( - *ifo->getList("Mod_PlayerList").front()->getList("ActionList").front()); + *ifo->getList("Mod_PlayerList").front()->getList("ActionList").front(), + snapshotIdentityContext()); EXPECT_EQ(saved.actionId, 24u); EXPECT_EQ(saved.groupActionId, 16); EXPECT_EQ(saved.declaredParameterCount, 3); @@ -1579,8 +2184,10 @@ TEST_F(SnapshotFixture, follow_leader_and_later_action_preserve_transition_queue const auto &savedQueue = ifo->getList("Mod_PlayerList").front()->getList("ActionList"); ASSERT_EQ(savedQueue.size(), 2); - auto savedFollow = SavedActionRecord::fromGff(*savedQueue[0]); - auto savedLater = SavedActionRecord::fromGff(*savedQueue[1]); + auto savedFollow = SavedActionRecord::fromGff( + *savedQueue[0], snapshotIdentityContext()); + auto savedLater = SavedActionRecord::fromGff( + *savedQueue[1], snapshotIdentityContext()); EXPECT_EQ(savedFollow.actionId, 61u); EXPECT_EQ(savedFollow.groupActionId, 40); EXPECT_EQ(savedFollow.declaredParameterCount, 0); diff --git a/test/game/object.cpp b/test/game/object.cpp index 31725ee7b..8b0606231 100644 --- a/test/game/object.cpp +++ b/test/game/object.cpp @@ -27,6 +27,8 @@ #include "reone/game/action/opendoor.h" #include "reone/game/action/unlockobject.h" #include "reone/game/equipmentrules.h" +#include "reone/game/d20/class.h" +#include "reone/game/d20/classes.h" #include "reone/game/game.h" #include "reone/game/gui/areatransition.h" #include "reone/game/gui/actionbar.h" @@ -83,6 +85,14 @@ using namespace reone::game; using namespace reone::resource; using namespace testing; +static SerializedIdentityContext testModuleIdentity() { + return SerializedIdentityContext::moduleGraph("test-module"); +} + +static SerializedIdentityContext testDetachedIdentity() { + return SerializedIdentityContext::detachedRecord("test-record"); +} + namespace reone::game { class TestStatusSummary : public StatusSummary { @@ -166,7 +176,7 @@ class MixedStuntTestAccess { } static std::shared_ptr participantCreature(DialogGUI &gui, const std::string &tag) { - return gui._participantByTag.at(tag).creature; + return gui._participantByTag.at(tag).creature.resolve(); } static std::shared_ptr participantModel(DialogGUI &gui, const std::string &tag) { @@ -424,6 +434,82 @@ std::shared_ptr makePlotTable() { return std::shared_ptr(builder.build()); } +struct VitalityTestClass { + NiceMock strings; + NiceMock twoDas; + Classes classes {strings, twoDas}; + std::shared_ptr clazz; + + explicit VitalityTestClass(int hitDie) { + TwoDA::Builder skills; + auto skillsTable = std::shared_ptr(skills.build()); + TwoDA::Builder saves; + saves.columns({"level", "fortsave", "refsave", "willsave"}) + .row({"1", "0", "0", "0"}); + auto savesTable = std::shared_ptr(saves.build()); + TwoDA::Builder attacks; + attacks.columns({"bab"}).row({"0"}); + auto attacksTable = std::shared_ptr(attacks.build()); + + ON_CALL(twoDas, get("skills")).WillByDefault(Return(skillsTable)); + ON_CALL(twoDas, get("save")).WillByDefault(Return(savesTable)); + ON_CALL(twoDas, get("attack")).WillByDefault(Return(attacksTable)); + + TwoDA::Builder classesTable; + classesTable.columns({ + "name", "description", "hitdie", "skillpointbase", + "str", "dex", "con", "int", "wis", "cha", + "skillstable", "savingthrowtable", "attackbonustable", + "featstable", "featgain", "spellgaintable"}) + .row({ + "0", "0", std::to_string(hitDie), "0", + "10", "10", "10", "10", "10", "10", + "unused", "save", "attack", "", "", ""}); + + clazz = std::make_shared( + ClassType::Soldier, classes, strings, twoDas); + clazz->load(*classesTable.build(), 0); + } +}; + +std::shared_ptr vitalityCreatureRecord( + int baseHitPoints, + int serializedCurrentHitPoints, + int cachedMaxHitPoints, + int constitution, + int level, + std::vector feats = {}, + bool isPC = false) { + auto classRecord = Gff::Builder() + .field(Gff::Field::newInt( + "Class", static_cast(ClassType::Soldier))) + .field(Gff::Field::newShort("ClassLevel", level)) + .build(); + std::vector> featRecords; + for (auto feat : feats) { + featRecords.push_back( + Gff::Builder() + .field(Gff::Field::newWord( + "Feat", static_cast(feat))) + .build()); + } + return Gff::Builder() + .field(Gff::Field::newDword("ObjectId", 82)) + .field(Gff::Field::newShort("HitPoints", baseHitPoints)) + .field(Gff::Field::newShort( + "CurrentHitPoints", serializedCurrentHitPoints)) + .field(Gff::Field::newShort("MaxHitPoints", cachedMaxHitPoints)) + .field(Gff::Field::newByte("IsPC", isPC)) + .field(Gff::Field::newByte("Con", constitution)) + .field(Gff::Field::newList("ClassList", {classRecord})) + .field(Gff::Field::newList("FeatList", std::move(featRecords))) + .field(Gff::Field::newDword("Appearance_Type", 0)) + .field(Gff::Field::newWord("SoundSetFile", 0xffff)) + .field(Gff::Field::newByte("BodyBag", 0xff)) + .field(Gff::Field::newByte("PerceptionRange", 0xff)) + .build(); +} + std::shared_ptr makeJournalWithPlotXP() { auto entry = std::make_shared( 0, @@ -604,7 +690,7 @@ std::shared_ptr makeTransitionDoor( .field(Gff::Field::newCExoLocString("TransitionDestin", -1, "Destination Area")) .build(); auto door = game.newDoor(); - door->deserialize(*gff); + door->deserialize(*gff, SerializedIdentityContext::templateResource()); return door; } @@ -624,7 +710,7 @@ std::shared_ptr addEntryWaypoint( .field(Gff::Field::newFloat("YOrientation", glm::cos(facing))) .build(); auto waypoint = game.newWaypoint(); - waypoint->deserialize(*gff); + waypoint->deserialize(*gff, SerializedIdentityContext::templateResource()); area->add(waypoint); return waypoint; } @@ -663,7 +749,7 @@ std::shared_ptr makePlainDoor( .field(Gff::Field::newFloat("Z", position.z)) .build(); auto door = game.newDoor(); - door->deserialize(*gff); + door->deserialize(*gff, SerializedIdentityContext::templateResource()); return door; } @@ -688,7 +774,7 @@ std::shared_ptr makeMovingCreature( .field(Gff::Field::newResRef("ScriptOnBlocked", std::move(onBlocked))) .build(); auto creature = game.newCreature(); - creature->deserialize(*gff); + creature->deserialize(*gff, SerializedIdentityContext::templateResource()); return creature; } @@ -768,7 +854,7 @@ std::shared_ptr makeItem(Game &game, std::string tag, int baseItem, int st .field(Gff::Field::newWord("StackSize", stackSize)) .build(); auto item = game.newItem(); - item->deserialize(*gff); + item->deserialize(*gff, SerializedIdentityContext::templateResource()); item->setDropable(true); return item; } @@ -866,15 +952,16 @@ TEST(Creature, restores_the_creation_script_flag_only_from_a_saved_record) { // A blueprint says nothing about creation, so the creature still owes its // OnSpawn. A save record is authoritative either way. auto blueprint = game.newCreature(); - blueprint->deserialize(*record(std::nullopt)); + blueprint->deserialize( + *record(std::nullopt), SerializedIdentityContext::templateResource()); EXPECT_FALSE(blueprint->spawnScriptFired()); auto restoredSpawned = game.newCreature(); - restoredSpawned->deserialize(*record(1)); + restoredSpawned->deserialize(*record(1), testModuleIdentity()); EXPECT_TRUE(restoredSpawned->spawnScriptFired()); auto restoredUnspawned = game.newCreature(); - restoredUnspawned->deserialize(*record(0)); + restoredUnspawned->deserialize(*record(0), testModuleIdentity()); EXPECT_FALSE(restoredUnspawned->spawnScriptFired()); } @@ -925,8 +1012,7 @@ TEST(EquipmentStack, reequips_a_restored_offhand_lightsaber_after_inventory_merg ASSERT_TRUE(actor->equip(InventorySlots::leftWeapon, offHand)); actor->addItem(inventorySaber); - actor->unequip(offHand); - actor->addItem(offHand); + ASSERT_TRUE(actor->moveEquippedItemTo(offHand, *actor)); ASSERT_EQ(1u, actor->items().size()); ASSERT_EQ(inventorySaber, actor->items().front()); @@ -1347,7 +1433,8 @@ TEST(DialogGUI, should_hold_mixed_stunt_assignment_and_restore_on_drop_or_teardo engine.services().audio, engine.services().resource); auto modelNode = graph.newModel(*stuntModel, scene::ModelUsage::Creature); - auto player = std::make_shared(1, "player", game, engine.services()); + auto player = game.newObject( + "player", game, engine.services()); player->setSceneNode(modelNode); player->setPosition(glm::vec3(1.0f, 2.0f, 3.0f)); player->setFacing(0.75f); @@ -1495,7 +1582,9 @@ struct DialogAnimScene { uint32_t id, std::string tag, const std::shared_ptr &model) { - auto creature = std::make_shared(id, std::move(tag), game, engine.services()); + (void)id; + auto creature = game.newObject( + std::move(tag), game, engine.services()); creature->setSceneNode(graph.newModel(*model, scene::ModelUsage::Creature)); return creature; } @@ -2528,7 +2617,8 @@ TEST(TransitionPresentationPortals, should_expose_authored_transitions_without_t "authored_module", "authored_waypoint", "", - "Authored Destination")); + "Authored Destination"), + SerializedIdentityContext::templateResource()); trigger->setPosition(glm::vec3(4.0f, 0.0f, 0.0f)); area->add(trigger); area->add(leader); @@ -2585,11 +2675,17 @@ TEST(TransitionPresentationPortals, should_ignore_non_transitions_and_expose_emp auto area = game.newArea(); auto nonTransition = game.newTrigger(); - nonTransition->deserialize(*makeTransitionTriggerGff("", "", "", "Not a transition")); + nonTransition->deserialize( + *makeTransitionTriggerGff("", "", "", "Not a transition"), + SerializedIdentityContext::templateResource()); auto emptyDestination = game.newTrigger(); - emptyDestination->deserialize(*makeTransitionTriggerGff("empty_module", "empty_waypoint", "", "")); + emptyDestination->deserialize( + *makeTransitionTriggerGff("empty_module", "empty_waypoint", "", ""), + SerializedIdentityContext::templateResource()); auto missingDestination = game.newTrigger(); - missingDestination->deserialize(*makeTransitionTriggerGff("missing_module", "missing_waypoint")); + missingDestination->deserialize( + *makeTransitionTriggerGff("missing_module", "missing_waypoint"), + SerializedIdentityContext::templateResource()); area->add(nonTransition); area->add(emptyDestination); area->add(missingDestination); @@ -2664,7 +2760,9 @@ TEST(LinkedDoorTransition, should_destroy_generated_threshold_with_its_source_do game.party().setPlayer(leader); auto authored = game.newTrigger(); - authored->deserialize(*Gff::Builder().build()); + authored->deserialize( + *Gff::Builder().build(), + SerializedIdentityContext::templateResource()); area->add(authored); area->add(door); area->add(leader); @@ -2734,7 +2832,7 @@ TEST(LinkedDoorTransition, should_rearm_after_party_unload_and_exit_reentry) { // Module transitions cache Area/Trigger instances. Party unload removes // the old tenant, and loading the party again models revisiting the module. - area->unloadParty(); + area->retirePartyAreaRuntime(); EXPECT_FALSE(trigger->isTenant(leader)); area->loadParty(glm::vec3(0.0f, -1.0f, 0.0f), 0.0f); game.scheduleModuleTransition("", ""); @@ -2770,6 +2868,7 @@ TEST(LinkedDoorTransition, should_reject_npcs_and_companions) { npc->setPosition(glm::vec3(1.9f, -1.0f, 0.0f)); game.party().addMember(kNpcPlayer, leader); game.party().setPlayer(leader); + game.party().addAvailableMember(0, companion); game.party().addMember(0, companion); area->add(door); area->add(leader); @@ -2806,7 +2905,9 @@ struct ModuleTransitionActivatorFixture : TestWithParam { testSceneGraph(engine); area = game.newArea(); trigger = game.newTrigger(); - trigger->deserialize(*makeTransitionTriggerGff("ebo_m12aa", "K_EBN_RAMP_ENTRANCE")); + trigger->deserialize( + *makeTransitionTriggerGff("ebo_m12aa", "K_EBN_RAMP_ENTRANCE"), + SerializedIdentityContext::templateResource()); trigger->setPosition(glm::vec3(0.0f, 0.0f, 0.0f)); area->add(trigger); } @@ -2863,6 +2964,7 @@ TEST_P(ModuleTransitionActivatorFixture, controlled_companion_may_transition) { auto companion = creatureBelowTrigger(); game.party().addMember(kNpcPlayer, player); game.party().setActualPlayer(player); + game.party().addAvailableMember(0, companion); game.party().setControlledMember(0, companion); ASSERT_EQ(game.party().getLeader(), companion); @@ -2880,6 +2982,7 @@ TEST_P(ModuleTransitionActivatorFixture, player_character_may_transition_while_a area->add(companion); game.party().addMember(kNpcPlayer, player); game.party().setActualPlayer(player); + game.party().addAvailableMember(0, companion); game.party().setControlledMember(0, companion); ASSERT_NE(game.party().getLeader(), player); @@ -2896,6 +2999,7 @@ TEST_P(ModuleTransitionActivatorFixture, following_companion_may_not_transition) game.party().addMember(kNpcPlayer, player); game.party().setPlayer(player); game.party().setActualPlayer(player); + game.party().addAvailableMember(0, follower); game.party().addMember(0, follower); ASSERT_TRUE(stepIn(follower)); @@ -2930,6 +3034,7 @@ TEST_P(ModuleTransitionActivatorFixture, follower_does_not_bounce_the_party_back game.party().addMember(kNpcPlayer, player); game.party().setPlayer(player); game.party().setActualPlayer(player); + game.party().addAvailableMember(0, follower); game.party().addMember(0, follower); ASSERT_TRUE(stepIn(follower)); @@ -3060,7 +3165,7 @@ TEST(LinkedDoorTransition, should_preserve_reusable_authored_type1_trigger_lifec "authored_waypoint", "override_transition"); auto trigger = game.newTrigger(); - trigger->deserialize(*gff); + trigger->deserialize(*gff, SerializedIdentityContext::templateResource()); auto area = game.newArea(); // The subject here is the trigger's own enter/exit/re-enter lifecycle, so // the mover is the party leader, who may activate a module transition. @@ -3181,7 +3286,7 @@ std::shared_ptr makeLifecycleDoor( .field(Gff::Field::newByte("OpenState", static_cast(openState))) .build(); auto door = game.newDoor(); - door->deserialize(*gff); + door->deserialize(*gff, SerializedIdentityContext::templateResource()); return door; } @@ -3552,6 +3657,7 @@ TEST(Party, should_award_xp_to_pool_and_sync_current_members) { auto companion = game.newCreature(); game.party().addMember(kNpcPlayer, player); game.party().setPlayer(player); + game.party().addAvailableMember(0, companion); game.party().addMember(0, companion); game.party().awardXP(100, XPSource::Plot); @@ -3572,6 +3678,7 @@ TEST(Party, should_set_xp_pool_and_sync_current_members) { auto companion = game.newCreature(); game.party().addMember(kNpcPlayer, player); game.party().setPlayer(player); + game.party().addAvailableMember(0, companion); game.party().addMember(0, companion); game.party().setXP(250); @@ -3614,6 +3721,7 @@ TEST(Party, should_restore_saved_pool_after_reset_and_sync_late_member) { game.party().setXP(750); auto lateCompanion = game.newCreature(); + game.party().addAvailableMember(0, lateCompanion); game.party().addMember(0, lateCompanion); EXPECT_EQ(game.party().xp(), 750); @@ -3632,6 +3740,7 @@ TEST(Party, should_sync_member_added_after_xp_gain) { game.party().awardXP(100, XPSource::Combat); auto latecomer = game.newCreature(); + game.party().addAvailableMember(0, latecomer); game.party().addMember(0, latecomer); EXPECT_EQ(latecomer->xp(), 100); @@ -3665,6 +3774,7 @@ TEST(XPStatusSummary, should_accumulate_each_positive_party_award_once) { auto companion = game.newCreature(); game.party().addMember(kNpcPlayer, player); game.party().setPlayer(player); + game.party().addAvailableMember(0, companion); game.party().addMember(0, companion); game.party().awardXP(100, XPSource::Plot); @@ -3703,6 +3813,7 @@ TEST(XPStatusSummary, should_preserve_negative_accounting_without_received_notif auto companion = game.newCreature(); game.party().addMember(kNpcPlayer, player); game.party().setPlayer(player); + game.party().addAvailableMember(0, companion); game.party().addMember(0, companion); game.party().setXP(100); @@ -3980,6 +4091,7 @@ TEST(Party, should_route_item_acquired_by_companion_to_shared_player_inventory) auto companion = game.newCreature(); game.party().addMember(kNpcPlayer, player); game.party().setPlayer(player); + game.party().addAvailableMember(0, companion); game.party().addMember(0, companion); auto receiver = game.party().sharedInventoryReceiver(companion); @@ -4060,13 +4172,14 @@ TEST(Object, should_restore_saved_appearance_after_unequipping_loaded_disguise) .field(Gff::Field::newList("Equip_ItemList", {makeDisguiseItemGff(2)})) .build(); auto creature = game.newCreature(); - creature->deserialize(*creatureGff); + creature->deserialize( + *creatureGff, SerializedIdentityContext::templateResource()); ASSERT_EQ(creature->appearance(), 2); auto disguise = creature->getEquippedItem(InventorySlots::body); ASSERT_TRUE(disguise); - creature->unequip(disguise); + ASSERT_TRUE(creature->takeEquippedItem(disguise)); EXPECT_EQ(creature->appearance(), 1); } @@ -4341,6 +4454,7 @@ TEST(CreatureBlockedByDoor, should_run_the_script_authored_on_each_creature) { BlockedDoorFixture fixture; auto npc = fixture.addCreature("k_def_blocked01"); auto companion = fixture.addCreature("k_hen_blocked01"); + fixture.game.party().addAvailableMember(0, companion); fixture.game.party().addMember(0, companion); // Each creature runs what its own template names. The engine picks neither @@ -4688,7 +4802,8 @@ TEST(OpenDoorAction, still_refuses_a_locked_door_and_fires_on_fail_to_open) { auto gff = Gff::Builder() .field(Gff::Field::newResRef("OnFailToOpen", "door_on_fail")) .build(); - fixture.door->deserialize(*gff); + fixture.door->deserialize( + *gff, SerializedIdentityContext::templateResource()); fixture.countScriptRuns("door_on_fail"); npc->setPosition(fixture.door->position()); @@ -4757,7 +4872,7 @@ void makeHumanoid(TestCreature &creature, TestEngine &engine) { .field(Gff::Field::newByte("BodyBag", 0xff)) .field(Gff::Field::newByte("PerceptionRange", 0xff)) .build(); - creature.deserialize(*gff); + creature.deserialize(*gff, SerializedIdentityContext::templateResource()); ASSERT_EQ(Creature::ModelType::Character, creature.modelType()); } @@ -4846,17 +4961,18 @@ TEST(OverlayAnimation, should_leave_the_action_queue_alone) { Game game(GameID::TSL, "", engine.options(), engine.services(), console); OverlayFixture fixture; setUpOverlay(fixture, engine); - TestCreature creature(1, "test", game, engine.services()); - creature.setSceneNode(fixture.node); - creature.setMovementType(Creature::MovementType::Run); + auto creature = game.newObject( + "test", game, engine.services()); + creature->setSceneNode(fixture.node); + creature->setMovementType(Creature::MovementType::Run); auto pending = game.newAction(AnimationType::LoopingPause, 1.0f, 0.0f); - creature.addAction(pending); - ASSERT_EQ(1u, creature.actions().size()); + creature->addAction(pending); + ASSERT_EQ(1u, creature->actions().size()); - creature.playOverlayAnimation(AnimationType::FireForgetDiveRoll); + creature->playOverlayAnimation(AnimationType::FireForgetDiveRoll); - EXPECT_EQ(1u, creature.actions().size()); - EXPECT_EQ(pending, creature.getCurrentAction()); + EXPECT_EQ(1u, creature->actions().size()); + EXPECT_EQ(pending, creature->getCurrentAction()); EXPECT_FALSE(pending->isCompleted()); } @@ -4962,22 +5078,27 @@ TEST(SavedRuntimeState, restores_explicit_object_identity_and_allocator_cursors) engine.init(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .WillRepeatedly(Return(makeBaseItemsTable())); auto ifo = Gff::Builder() .field(Gff::Field::newDword("Mod_NextObjId0", 700)) .field(Gff::Field::newDword64("Mod_Effect_NxtId", 900)) .build(); - game.prepareSavedRuntimeNamespace(*ifo); + game.prepareSavedRuntimeNamespace(*ifo, testModuleIdentity()); auto saved = Gff::Builder() .field(Gff::Field::newDword("ObjectId", 650)) .build(); - auto item = game.newItem(*saved); - item->deserializeRuntimeState(*saved); - - EXPECT_EQ(650u, item->id()); - EXPECT_EQ(item, game.getObjectById(650)); - EXPECT_EQ(700u, game.newItem()->id()); + auto item = game.newItem(*saved, testModuleIdentity()); + item->deserializeRuntimeState( + *saved, + SerializedIdentityContext::moduleGraph("test-module")); + + EXPECT_EQ(700u, item->id()); + EXPECT_EQ(item, game.getObjectBySavedId(650)); + EXPECT_FALSE(game.getObjectById(650)); + EXPECT_EQ(701u, game.newItem()->id()); EXPECT_EQ(900u, game.nextEffectId()); } @@ -4990,13 +5111,13 @@ TEST(SavedRuntimeState, accepts_retail_zero_object_cursor_but_rejects_reserved_n auto unavailableCursor = Gff::Builder() .field(Gff::Field::newDword("Mod_NextObjId0", 0)) .build(); - EXPECT_NO_THROW(game.prepareSavedRuntimeNamespace(*unavailableCursor)); + EXPECT_NO_THROW(game.prepareSavedRuntimeNamespace(*unavailableCursor, testModuleIdentity())); EXPECT_EQ(2u, game.newItem()->id()); auto reservedCursor = Gff::Builder() .field(Gff::Field::newDword("Mod_NextObjId0", 1)) .build(); - EXPECT_THROW(game.prepareSavedRuntimeNamespace(*reservedCursor), ValidationException); + EXPECT_THROW(game.prepareSavedRuntimeNamespace(*reservedCursor, testModuleIdentity()), ValidationException); } TEST(SavedRuntimeState, accepts_retail_low_ids_but_rejects_invalid_and_duplicate_object_ids) { @@ -5004,6 +5125,8 @@ TEST(SavedRuntimeState, accepts_retail_low_ids_but_rejects_invalid_and_duplicate engine.init(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .WillRepeatedly(Return(makeBaseItemsTable())); auto reserved = Gff::Builder() .field(Gff::Field::newDword("ObjectId", 1)) @@ -5015,26 +5138,93 @@ TEST(SavedRuntimeState, accepts_retail_low_ids_but_rejects_invalid_and_duplicate .field(Gff::Field::newDword("ObjectId", 42)) .build(); - EXPECT_NO_THROW(game.newItem(*reserved)); - EXPECT_THROW(game.newItem(*reserved), ValidationException); - EXPECT_THROW(game.newItem(*invalid), ValidationException); - EXPECT_NO_THROW(game.newItem(*saved)); - EXPECT_THROW(game.newItem(*saved), ValidationException); + EXPECT_NO_THROW(game.newItem(*reserved, testModuleIdentity())); + EXPECT_THROW(game.newItem(*reserved, testModuleIdentity()), ValidationException); + EXPECT_THROW(game.newItem(*invalid, testModuleIdentity()), ValidationException); + EXPECT_NO_THROW(game.newItem(*saved, testModuleIdentity())); + EXPECT_THROW(game.newItem(*saved, testModuleIdentity()), ValidationException); } -TEST(SavedRuntimeState, gives_structural_module_a_transient_runtime_identity) { +TEST(SavedRuntimeState, maps_structural_module_through_contextual_retail_slot_zero) { TestEngine engine; engine.init(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); auto module = game.newSavedModule(); - auto area = game.newSavedArea(0); + TestGameModule::registerSavedModuleReferenceTarget( + game, module, testModuleIdentity()); EXPECT_EQ(2u, module->id()); - EXPECT_EQ(0u, area->id()); + EXPECT_EQ(module, game.getObjectBySavedId(kSavedRuntimeModuleObjectId)); EXPECT_EQ(module, game.getObjectById(module->id())); - EXPECT_EQ(2u, engine.gameModule().objectRegistrySize(game)); + EXPECT_FALSE(module->serializedObjectIdentity()); + EXPECT_EQ(1u, engine.gameModule().objectRegistrySize(game)); + + const size_t registrySize = engine.gameModule().objectRegistrySize(game); + const uint32_t nextObjectId = TestGameModule::nextObjectId(game); + + EXPECT_THROW( + game.newSavedArea(kSavedRuntimeModuleObjectId, testModuleIdentity()), + ValidationException); + EXPECT_EQ(registrySize, engine.gameModule().objectRegistrySize(game)); + EXPECT_EQ(nextObjectId, TestGameModule::nextObjectId(game)); + EXPECT_EQ(module, game.getObjectBySavedId(kSavedRuntimeModuleObjectId)); +} + +TEST(SavedRuntimeState, rejects_an_owned_record_in_the_structural_module_slot) { + TestEngine engine; + engine.init(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + + auto area = Gff::Builder() + .field(Gff::Field::newDword( + "ObjectId", kSavedRuntimeModuleObjectId)) + .build(); + auto ifo = Gff::Builder() + .field(Gff::Field::newList("Mod_Area_list", {area})) + .build(); + game.prepareSavedRuntimeNamespace(*ifo, testModuleIdentity()); + auto module = game.newSavedModule(); + + EXPECT_THROW( + TestGameModule::registerSavedModuleReferenceTarget( + game, module, testModuleIdentity()), + ValidationException); + EXPECT_FALSE(game.getObjectBySavedId(kSavedRuntimeModuleObjectId)); +} + +TEST(SavedRuntimeState, stages_structural_module_identity_with_runtime_publication) { + TestEngine engine; + engine.init(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + + auto occupied = game.newSavedModule(); + TestGameModule::registerSavedModuleReferenceTarget( + game, occupied, testModuleIdentity()); + const size_t registrySize = engine.gameModule().objectRegistrySize(game); + const uint32_t nextObjectId = TestGameModule::nextObjectId(game); + std::shared_ptr candidate; + std::vector> noObsolete; + + EXPECT_THROW( + game.replaceRuntimeObjectGraph( + noObsolete, + [&]() { + candidate = game.newSavedModule(); + TestGameModule::registerSavedModuleReferenceTarget( + game, candidate, testModuleIdentity()); + }, + []() noexcept {}), + ValidationException); + + ASSERT_TRUE(candidate); + EXPECT_FALSE(candidate->isRuntimeLive()); + EXPECT_EQ(registrySize, engine.gameModule().objectRegistrySize(game)); + EXPECT_EQ(nextObjectId, TestGameModule::nextObjectId(game)); + EXPECT_EQ(occupied, game.getObjectBySavedId(kSavedRuntimeModuleObjectId)); } TEST(SavedRuntimeState, reserves_the_actual_retail_graph_before_owner_local_allocations) { @@ -5042,16 +5232,27 @@ TEST(SavedRuntimeState, reserves_the_actual_retail_graph_before_owner_local_allo engine.init(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + NiceMock sceneGraph; + ON_CALL(engine.sceneModule().graphs(), get(_)) + .WillByDefault(ReturnRef(sceneGraph)); + TwoDA::Builder placeables; + placeables.columns({"modelname"}); + placeables.row({""}); + EXPECT_CALL(engine.resourceModule().twoDas(), get("placeables")) + .WillRepeatedly(Return(std::shared_ptr(placeables.build()))); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .WillRepeatedly(Return(makeBaseItemsTable())); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); auto area = Gff::Builder() - .field(Gff::Field::newDword("ObjectId", 0)) + .field(Gff::Field::newDword("ObjectId", 1)) .build(); auto ifo = Gff::Builder() .field(Gff::Field::newDword("Mod_NextObjId0", 0)) .field(Gff::Field::newList("Mod_Area_list", {area})) .build(); auto trigger = Gff::Builder() - .field(Gff::Field::newDword("ObjectId", 1)) + .field(Gff::Field::newDword("ObjectId", 2)) .build(); auto placeable = Gff::Builder() .field(Gff::Field::newDword("ObjectId", 50)) @@ -5061,26 +5262,29 @@ TEST(SavedRuntimeState, reserves_the_actual_retail_graph_before_owner_local_allo .field(Gff::Field::newList("Placeable List", {placeable})) .build(); - game.prepareSavedRuntimeNamespace(*ifo); - game.reserveSavedObjectIds(*git); + game.prepareSavedRuntimeNamespace(*ifo, testModuleIdentity()); + game.reserveSavedObjectIds(*git, testModuleIdentity(), SerializedGraphRoot::AreaGit); - for (uint32_t expected = 2; expected < 50; ++expected) { + for (uint32_t expected = 3; expected < 50; ++expected) { EXPECT_EQ(expected, game.newItem()->id()); } EXPECT_EQ(51u, game.newItem()->id()); auto module = game.newSavedModule(); - auto savedArea = game.newSavedArea(0); - auto savedTrigger = game.newTrigger(*trigger); - auto savedPlaceable = game.newPlaceable(*placeable); + auto savedArea = game.newSavedArea(1, testModuleIdentity()); + auto savedTrigger = game.newTrigger(*trigger, testModuleIdentity()); + auto savedPlaceable = game.newPlaceable(*placeable, testModuleIdentity()); EXPECT_EQ(52u, module->id()); - EXPECT_EQ(0u, savedArea->id()); - EXPECT_EQ(1u, savedTrigger->id()); - EXPECT_EQ(50u, savedPlaceable->id()); + EXPECT_EQ(53u, savedArea->id()); + EXPECT_EQ(54u, savedTrigger->id()); + EXPECT_EQ(55u, savedPlaceable->id()); + EXPECT_EQ(savedArea, game.getObjectBySavedId(1)); + EXPECT_EQ(savedTrigger, game.getObjectBySavedId(2)); + EXPECT_EQ(savedPlaceable, game.getObjectBySavedId(50)); EXPECT_EQ(module, game.getObjectById(module->id())); - EXPECT_EQ(53u, engine.gameModule().objectRegistrySize(game)); - EXPECT_THROW(game.newPlaceable(*placeable), ValidationException); + EXPECT_EQ(52u, engine.gameModule().objectRegistrySize(game)); + EXPECT_THROW(game.newPlaceable(*placeable, testModuleIdentity()), ValidationException); } TEST(SavedRuntimeState, restores_swvar_boolean_and_numeric_locals) { @@ -5110,7 +5314,9 @@ TEST(SavedRuntimeState, restores_swvar_boolean_and_numeric_locals) { .build(); auto item = game.newItem(); - item->deserializeRuntimeState(*saved); + item->deserializeRuntimeState( + *saved, + SerializedIdentityContext::moduleGraph("test-module")); EXPECT_TRUE(item->getLocalBoolean(31)); EXPECT_TRUE(item->getLocalBoolean(34)); @@ -5124,6 +5330,8 @@ TEST(SavedRuntimeState, resolves_references_only_after_saved_graph_construction) engine.init(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .WillRepeatedly(Return(makeBaseItemsTable())); auto sourceGff = Gff::Builder() .field(Gff::Field::newDword("ObjectId", 80)) @@ -5133,12 +5341,16 @@ TEST(SavedRuntimeState, resolves_references_only_after_saved_graph_construction) auto targetGff = Gff::Builder() .field(Gff::Field::newDword("ObjectId", 81)) .build(); - auto source = game.newItem(*sourceGff); - source->deserializeRuntimeState(*sourceGff); + auto source = game.newItem(*sourceGff, testModuleIdentity()); + source->deserializeRuntimeState( + *sourceGff, + SerializedIdentityContext::moduleGraph("test-module")); EXPECT_FALSE(source->savedReference("CreatorId")); - auto target = game.newItem(*targetGff); - target->deserializeRuntimeState(*targetGff); + auto target = game.newItem(*targetGff, testModuleIdentity()); + target->deserializeRuntimeState( + *targetGff, + SerializedIdentityContext::moduleGraph("test-module")); game.resolveSavedObjectReferences(); EXPECT_EQ(target, source->savedReference("CreatorId")); @@ -5150,6 +5362,8 @@ TEST(SavedRuntimeState, preserves_and_binds_saved_encounter_runtime_state) { engine.init(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .WillRepeatedly(Return(makeBaseItemsTable())); auto areaObject = Gff::Builder() .field(Gff::Field::newDword("AreaObject", 90)) @@ -5174,12 +5388,11 @@ TEST(SavedRuntimeState, preserves_and_binds_saved_encounter_runtime_state) { .field(Gff::Field::newByte("Started", 1)) .build(); - auto encounter = game.newEncounter(*saved); - encounter->deserialize(*saved); + auto encounter = game.newEncounter(*saved, testModuleIdentity()); auto targetGff = Gff::Builder() .field(Gff::Field::newDword("ObjectId", 90)) .build(); - auto target = game.newItem(*targetGff); + auto target = game.newItem(*targetGff, testModuleIdentity()); game.resolveSavedObjectReferences(); const auto &state = encounter->savedRuntimeState(); @@ -5202,6 +5415,171 @@ TEST(SavedRuntimeState, preserves_and_binds_saved_encounter_runtime_state) { EXPECT_EQ(target, encounter->savedAreaObject(0)); } +TEST(CreatureVitality, restores_full_health_from_base_axis_with_permanent_bonuses) { + TestEngine engine; + engine.init(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + VitalityTestClass soldier(10); + EXPECT_CALL(engine.gameModule().classes(), get(ClassType::Soldier)) + .WillRepeatedly(Return(soldier.clazz)); + EXPECT_CALL(engine.resourceModule().twoDas(), get("appearance")) + .WillRepeatedly(Return(makeAppearanceTable())); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + EXPECT_CALL( + static_cast(engine.services().game.portraits), + getTextureByAppearance(_)) + .Times(AnyNumber()); + + auto record = vitalityCreatureRecord( + 30, 30, 36, 12, 3, {FeatType::Toughness}); + auto creature = game.newCreature( + *record, SerializedIdentityContext::templateResource("end_trask")); + + EXPECT_EQ(30, creature->hitPoints()); + EXPECT_EQ(36, creature->maxHitPoints()); + EXPECT_EQ(36, creature->currentHitPoints()); + EXPECT_EQ(30, creature->serializedCurrentHitPoints()); +} + +TEST(CreatureVitality, restores_and_serializes_genuine_damage_on_base_axis) { + TestEngine engine; + engine.init(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + VitalityTestClass soldier(10); + EXPECT_CALL(engine.gameModule().classes(), get(ClassType::Soldier)) + .WillRepeatedly(Return(soldier.clazz)); + EXPECT_CALL(engine.resourceModule().twoDas(), get("appearance")) + .WillRepeatedly(Return(makeAppearanceTable())); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + EXPECT_CALL( + static_cast(engine.services().game.portraits), + getTextureByAppearance(_)) + .Times(AnyNumber()); + + auto record = vitalityCreatureRecord( + 30, 26, 36, 12, 3, {FeatType::Toughness}); + auto creature = game.newCreature(*record, testModuleIdentity()); + + EXPECT_EQ(36, creature->maxHitPoints()); + EXPECT_EQ(32, creature->currentHitPoints()); + EXPECT_EQ(26, creature->serializedCurrentHitPoints()); +} + +TEST(CreatureVitality, restores_k1_and_k2_companion_witnesses_at_full_health) { + TestEngine engine; + engine.init(); + StubConsole console; + Game k1(GameID::KotOR, "", engine.options(), engine.services(), console); + Game k2(GameID::TSL, "", engine.options(), engine.services(), console); + VitalityTestClass soldier(10); + EXPECT_CALL(engine.gameModule().classes(), get(ClassType::Soldier)) + .WillRepeatedly(Return(soldier.clazz)); + EXPECT_CALL(engine.resourceModule().twoDas(), get("appearance")) + .WillRepeatedly(Return(makeAppearanceTable())); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + EXPECT_CALL( + static_cast(engine.services().game.portraits), + getTextureByAppearance(_)) + .Times(AnyNumber()); + + auto carth = k1.newCreature( + *vitalityCreatureRecord(40, 40, 44, 12, 4), + SerializedIdentityContext::templateResource("p_carth")); + auto mission = k1.newCreature( + *vitalityCreatureRecord(18, 18, 21, 12, 3), + SerializedIdentityContext::templateResource("p_mission")); + auto atton = k2.newCreature( + *vitalityCreatureRecord(18, 18, 24, 14, 3), + SerializedIdentityContext::templateResource("p_atton")); + + EXPECT_EQ(44, carth->currentHitPoints()); + EXPECT_EQ(44, carth->maxHitPoints()); + EXPECT_EQ(21, mission->currentHitPoints()); + EXPECT_EQ(21, mission->maxHitPoints()); + EXPECT_EQ(24, atton->currentHitPoints()); + EXPECT_EQ(24, atton->maxHitPoints()); +} + +TEST(CreatureVitality, permanent_bonus_changes_preserve_damage) { + TestEngine engine; + engine.init(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + VitalityTestClass soldier(10); + EXPECT_CALL(engine.gameModule().classes(), get(ClassType::Soldier)) + .WillRepeatedly(Return(soldier.clazz)); + EXPECT_CALL(engine.resourceModule().twoDas(), get("appearance")) + .WillRepeatedly(Return(makeAppearanceTable())); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + EXPECT_CALL( + static_cast(engine.services().game.portraits), + getTextureByAppearance(_)) + .Times(AnyNumber()); + + auto record = vitalityCreatureRecord( + 30, 26, 36, 12, 3, {FeatType::Toughness}); + auto creature = game.newCreature(*record, testModuleIdentity()); + ASSERT_EQ(32, creature->currentHitPoints()); + + creature->attributes().setAbilityScore(Ability::Constitution, 14); + creature->recalculatePermanentVitality(); + EXPECT_EQ(39, creature->maxHitPoints()); + EXPECT_EQ(35, creature->currentHitPoints()); + EXPECT_EQ(26, creature->serializedCurrentHitPoints()); + + creature->attributes().addFeat(FeatType::MasterToughness); + creature->recalculatePermanentVitality(); + EXPECT_EQ(42, creature->maxHitPoints()); + EXPECT_EQ(38, creature->currentHitPoints()); + EXPECT_EQ(26, creature->serializedCurrentHitPoints()); +} + +TEST(CreatureVitality, generated_character_starts_at_full_derived_vitality) { + TestEngine engine; + engine.init(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + VitalityTestClass soldier(10); + auto creature = game.newCreature(); + creature->attributes().addClassLevels(soldier.clazz.get(), 1); + creature->attributes().setAbilityScore(Ability::Constitution, 12); + + creature->initializeGeneratedVitality(); + + EXPECT_EQ(10, creature->hitPoints()); + EXPECT_EQ(11, creature->maxHitPoints()); + EXPECT_EQ(11, creature->currentHitPoints()); + EXPECT_EQ(10, creature->serializedCurrentHitPoints()); +} + +TEST(CreatureVitality, dead_serialized_creature_is_not_healed_by_derivation) { + TestEngine engine; + engine.init(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + VitalityTestClass soldier(10); + EXPECT_CALL(engine.gameModule().classes(), get(ClassType::Soldier)) + .WillRepeatedly(Return(soldier.clazz)); + EXPECT_CALL(engine.resourceModule().twoDas(), get("appearance")) + .WillRepeatedly(Return(makeAppearanceTable())); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + EXPECT_CALL( + static_cast(engine.services().game.portraits), + getTextureByAppearance(_)) + .Times(AnyNumber()); + + auto record = vitalityCreatureRecord( + 30, 0, 36, 12, 3, {FeatType::Toughness}); + auto creature = game.newCreature(*record, testModuleIdentity()); + + EXPECT_EQ(36, creature->maxHitPoints()); + EXPECT_EQ(0, creature->currentHitPoints()); + EXPECT_EQ(0, creature->serializedCurrentHitPoints()); + EXPECT_TRUE(creature->isDead()); +} + TEST(SavedRuntimeState, restores_saved_creature_death_from_current_hit_points) { TestEngine engine; engine.init(); @@ -5221,8 +5599,7 @@ TEST(SavedRuntimeState, restores_saved_creature_death_from_current_hit_points) { .field(Gff::Field::newByte("BodyBag", 0xff)) .field(Gff::Field::newByte("PerceptionRange", 0xff)) .build(); - auto creature = game.newCreature(*saved); - creature->deserialize(*saved); + auto creature = game.newCreature(*saved, testModuleIdentity()); EXPECT_EQ(0, creature->currentHitPoints()); EXPECT_TRUE(creature->isDead()); @@ -5252,26 +5629,19 @@ TEST(SavedRuntimeState, restores_retail_player_death_threshold_without_clamping_ }; auto incapacitatedState = makeSavedPlayer(0, 0x7fffffff); - auto incapacitated = game.newCreature(*incapacitatedState); - incapacitated->deserialize(*incapacitatedState); + auto incapacitated = game.newCreature(*incapacitatedState, testModuleIdentity()); EXPECT_EQ(0, incapacitated->currentHitPoints()); EXPECT_TRUE(incapacitated->isPC()); EXPECT_FALSE(incapacitated->isDead()); - incapacitated->setMaxHitPoints(36); - incapacitated->restorePrimaryPlayerHitPoints(); - EXPECT_EQ(36, incapacitated->currentHitPoints()); - EXPECT_FALSE(incapacitated->isDead()); - auto deadState = makeSavedPlayer(-10, 0x7ffffffe); - auto dead = game.newCreature(*deadState); - dead->deserialize(*deadState); + auto dead = game.newCreature(*deadState, testModuleIdentity()); EXPECT_EQ(-10, dead->currentHitPoints()); EXPECT_TRUE(dead->isDead()); } -TEST(SavedRuntimeState, k1_zero_hp_pc_is_dead_until_primary_player_publication) { +TEST(SavedRuntimeState, k1_zero_hp_pc_remains_dead_during_primary_player_publication) { TestEngine engine; engine.init(); StubConsole console; @@ -5292,18 +5662,12 @@ TEST(SavedRuntimeState, k1_zero_hp_pc_is_dead_until_primary_player_publication) .field(Gff::Field::newByte("BodyBag", 0xff)) .field(Gff::Field::newByte("PerceptionRange", 0xff)) .build(); - auto player = game.newCreature(*saved); - player->deserialize(*saved); + auto player = game.newCreature(*saved, testModuleIdentity()); - // K1 does not share K2's -10 incapacitation threshold. The saved creature - // remains exact until the coordinator identifies it as the primary player. + // K1 does not share K2's -10 incapacitation threshold. Publication must + // preserve the saved state rather than healing the primary player. EXPECT_EQ(0, player->currentHitPoints()); EXPECT_TRUE(player->isDead()); - - player->restorePrimaryPlayerHitPoints(); - - EXPECT_EQ(12, player->currentHitPoints()); - EXPECT_FALSE(player->isDead()); } TEST(SavedRuntimeState, keeps_min_one_hp_creature_alive_when_saved_at_zero) { TestEngine engine; @@ -5325,8 +5689,7 @@ TEST(SavedRuntimeState, keeps_min_one_hp_creature_alive_when_saved_at_zero) { .field(Gff::Field::newByte("BodyBag", 0xff)) .field(Gff::Field::newByte("PerceptionRange", 0xff)) .build(); - auto creature = game.newCreature(*saved); - creature->deserialize(*saved); + auto creature = game.newCreature(*saved, testModuleIdentity()); EXPECT_EQ(1, creature->currentHitPoints()); EXPECT_FALSE(creature->isDead()); @@ -5361,9 +5724,9 @@ TEST(SavedRuntimeState, detached_creatures_keep_nested_item_ids_owner_scoped) { .build(); auto first = game.newCreature(); - first->deserialize(*detachedCreature); + first->deserialize(*detachedCreature, testDetachedIdentity()); auto second = game.newCreature(); - EXPECT_NO_THROW(second->deserialize(*detachedCreature)); + EXPECT_NO_THROW(second->deserialize(*detachedCreature, testDetachedIdentity())); auto firstItem = first->getEquippedItem(InventorySlots::body); auto secondItem = second->getEquippedItem(InventorySlots::body); @@ -5406,10 +5769,16 @@ TEST(SavedRuntimeState, primary_health_publication_does_not_recover_unrelated_pc .field(Gff::Field::newList("Mod_PlayerList", {modulePlayer})) .build(); auto pc = makePlayer(0x7fffffff, 36, true); + auto availableNpc = Gff::Builder() + .field(Gff::Field::newByte("PT_NPC_AVAIL", 1)) + .field(Gff::Field::newByte("PT_NPC_SELECT", 1)) + .build(); auto partyTable = Gff::Builder() .field(Gff::Field::newInt("PT_CONTROLLED_NP", 0)) .field(Gff::Field::newByte("PT_NUM_MEMBERS", 0)) .field(Gff::Field::newList("PT_MEMBERS", {})) + .field(Gff::Field::newList( + "PT_AVAIL_NPCS", {availableNpc})) .build(); TestGameModule::publishPartyRuntimeState(game, *ifo, partyTable, pc); @@ -5420,10 +5789,43 @@ TEST(SavedRuntimeState, primary_health_publication_does_not_recover_unrelated_pc ASSERT_TRUE(actual); EXPECT_NE(moduleRuntime, actual); EXPECT_EQ(0, moduleRuntime->currentHitPoints()); - EXPECT_EQ(36, actual->currentHitPoints()); + EXPECT_EQ(0, actual->currentHitPoints()); EXPECT_FALSE(actual->isDead()); } -TEST(SavedRuntimeState, keeps_authoritative_owner_items_local_to_their_owner_namespace) { + +TEST(CreatureVitality, primary_player_publication_preserves_saved_damage) { + TestEngine engine; + engine.init(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + VitalityTestClass soldier(10); + EXPECT_CALL(engine.gameModule().classes(), get(ClassType::Soldier)) + .WillRepeatedly(Return(soldier.clazz)); + EXPECT_CALL(engine.resourceModule().twoDas(), get("appearance")) + .WillRepeatedly(Return(makeAppearanceTable())); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + EXPECT_CALL( + static_cast(engine.services().game.portraits), + getTextureByAppearance(_)) + .Times(AnyNumber()); + + auto savedPlayer = vitalityCreatureRecord( + 30, 26, 36, 12, 3, {FeatType::Toughness}, true); + auto ifo = Gff::Builder() + .field(Gff::Field::newList( + "Mod_PlayerList", {savedPlayer})) + .build(); + + TestGameModule::publishPartyRuntimeState( + game, *ifo, nullptr, nullptr); + + auto player = game.party().actualPlayer(); + ASSERT_TRUE(player); + EXPECT_EQ(36, player->maxHitPoints()); + EXPECT_EQ(32, player->currentHitPoints()); + EXPECT_EQ(26, player->serializedCurrentHitPoints()); +} +TEST(SavedRuntimeState, keeps_module_owned_items_authoritative_and_detached_inventory_local) { TestEngine engine; engine.init(); StubConsole console; @@ -5465,6 +5867,7 @@ TEST(SavedRuntimeState, keeps_authoritative_owner_items_local_to_their_owner_nam ASSERT_TRUE(equipped); EXPECT_NE(15u, equipped->id()); EXPECT_FALSE(game.getObjectById(15)); + EXPECT_EQ(equipped, game.getObjectBySavedId(15)); auto inventory = Gff::Builder() .field(Gff::Field::newList("ItemList", {savedLongsword})) @@ -5476,14 +5879,12 @@ TEST(SavedRuntimeState, keeps_authoritative_owner_items_local_to_their_owner_nam EXPECT_NE(15u, carried->id()); EXPECT_NE(equipped->id(), carried->id()); EXPECT_FALSE(game.getObjectById(15)); + EXPECT_EQ(equipped, game.getObjectBySavedId(15)); auto savedWorldItem = Gff::Builder() .field(Gff::Field::newDword("ObjectId", 15)) .build(); - auto worldItem = game.newItem(*savedWorldItem); - EXPECT_EQ(15u, worldItem->id()); - EXPECT_EQ(worldItem, game.getObjectById(15)); - EXPECT_THROW(game.newItem(*savedWorldItem), ValidationException); + EXPECT_THROW(game.newItem(*savedWorldItem, testModuleIdentity()), ValidationException); } namespace { @@ -5603,7 +6004,8 @@ struct HeartbeatFixture { auto placeable = game.newPlaceable(); placeable->deserialize(*makeHeartbeatPlaceableGff( - std::move(tag), std::move(onHeartbeat), std::move(onUsed))); + std::move(tag), std::move(onHeartbeat), std::move(onUsed)), + SerializedIdentityContext::templateResource()); area->add(placeable); return placeable; } diff --git a/test/game/partycontrol.cpp b/test/game/partycontrol.cpp index 674f6c167..8dbcac2a1 100644 --- a/test/game/partycontrol.cpp +++ b/test/game/partycontrol.cpp @@ -1,14 +1,25 @@ /* Copyright (c) 2026 The reone project contributors */ +#include + +#include #include #include "../fixtures/engine.h" +#include "../fixtures/game.h" +#include "../fixtures/scene.h" +#include "reone/game/action.h" +#include "reone/game/effect.h" +#include "reone/game/effect/modifyattacks.h" #include "reone/game/game.h" +#include "reone/game/object/area.h" #include "reone/game/object/creature.h" #include "reone/game/party.h" +#include "reone/game/room.h" #include "reone/game/script/routines.h" #include "reone/game/types.h" #include "reone/resource/types.h" +#include "reone/scene/collision.h" #include "reone/script/executioncontext.h" #include "reone/script/variable.h" @@ -16,9 +27,29 @@ using namespace reone; using namespace reone::game; using namespace reone::resource; using namespace reone::script; +using namespace testing; namespace { +class CountingAction : public reone::game::Action { +public: + CountingAction(Game &game, ServicesView &services, int &executions) : + reone::game::Action(game, services, ActionType::Invalid), + _executions(executions) { + } + + void execute( + std::shared_ptr self, + Object &actor, + float dt) override { + ++_executions; + complete(); + } + +private: + int &_executions; +}; + /** * A game plus its routine table, so SwitchPlayerCharacter can be called the way * the shipped prologue and Leviathan scripts call it. @@ -28,9 +59,26 @@ class ControlHarness : boost::noncopyable { explicit ControlHarness(GameID gameId) : _game(gameId, "", testEngine().options(), testEngine().services(), _console), _routines(gameId, &_game, &testEngine().services()) { + auto &director = testEngine().resourceModule().director(); + // testEngine() is process-global. Discard callbacks installed by a + // completed fixture before this harness exercises RemoveMember's + // retail save-before-control-transfer behavior. + Mock::VerifyAndClear(&director); + EXPECT_CALL(director, committedSaveWorkingState()) + .Times(AnyNumber()) + .WillRepeatedly(Invoke([this]() { return _committed; })); + EXPECT_CALL(director, adoptSaveWorkingState(_)) + .Times(AnyNumber()) + .WillRepeatedly(Invoke([this](auto state) { + _committed = std::move(state); + })); _routines.init(); } + ~ControlHarness() { + Mock::VerifyAndClear(&testEngine().resourceModule().director()); + } + /** A brand new game: no save has ever been loaded. */ std::shared_ptr startFreshGame() { auto player = _game.newCreature(); @@ -77,10 +125,85 @@ class ControlHarness : boost::noncopyable { StubConsole _console; Game _game; Routines _routines; + std::shared_ptr _committed { + std::make_shared()}; }; class PartyControl : public ::testing::TestWithParam {}; +/** A real Area around SwitchPlayerCharacter, with no resource load required. */ +class AreaControlHarness : boost::noncopyable { +public: + explicit AreaControlHarness(GameID gameId) : + _room("control", glm::vec3(0.0f), nullptr, nullptr, nullptr) { + + _engine.init(); + ON_CALL(_engine.sceneModule().graphs(), get(_)) + .WillByDefault(ReturnRef(_sceneGraph)); + ON_CALL(_sceneGraph, testElevation(_, _)) + .WillByDefault(Invoke([this]( + const glm::vec3 &position, + scene::Collision &collision) { + collision.intersection = position; + collision.user = &_room; + return true; + })); + + _game = std::make_unique( + gameId, "", _engine.options(), _engine.services(), _console); + auto &director = _engine.resourceModule().director(); + EXPECT_CALL(director, committedSaveWorkingState()) + .Times(AnyNumber()) + .WillRepeatedly(Invoke([this]() { return _committed; })); + EXPECT_CALL(director, adoptSaveWorkingState(_)) + .Times(AnyNumber()) + .WillRepeatedly(Invoke([this](auto state) { + _committed = std::move(state); + })); + + _routines = std::make_unique( + gameId, _game.get(), &_engine.services()); + _routines->init(); + + _player = _game->newCreature(); + _area = _game->newArea(); + TestGameModule::configureModuleSnapshot( + *_game, _area, _player, "control_module", "control_area"); + + _companion = _game->newCreature(); + _game->party().addAvailableMember(0, _companion); + _game->party().addMember(0, _companion); + _area->loadParty(glm::vec3(2.0f, 3.0f, 0.0f), 0.25f); + } + + int switchTo(int npc) { + Routine &routine = _routines->get( + _routines->getIndexByName("SwitchPlayerCharacter")); + ExecutionContext execution; + execution.routines = _routines.get(); + return routine.invoke({Variable::ofInt(npc)}, execution).intValue; + } + + Game &game() { return *_game; } + Area &area() { return *_area; } + Room &room() { return _room; } + const std::shared_ptr &player() const { return _player; } + const std::shared_ptr &companion() const { return _companion; } + +private: + TestEngine _engine; + NiceMock _sceneGraph; + Room _room; + StubConsole _console; + std::unique_ptr _game; + std::unique_ptr _routines; + std::shared_ptr _area; + std::shared_ptr _player; + std::shared_ptr _companion; + std::shared_ptr _committed { + std::make_shared()}; +}; + } // namespace TEST_P(PartyControl, aFreshGameEstablishesTheCanonicalPlayer) { @@ -170,5 +293,79 @@ TEST_P(PartyControl, switchingToAnUnknownNpcChangesNothing) { EXPECT_EQ(kNpcPlayer, harness.party().controlledNpc()); } +TEST_P(PartyControl, sameAreaControlSwitchPreservesRuntimeExecutionAndEffects) { + AreaControlHarness harness(GetParam()); + auto player = harness.player(); + auto companion = harness.companion(); + + int queuedExecutions = 0; + int delayedExecutions = 0; + auto queued = harness.game().newAction(queuedExecutions); + auto delayed = harness.game().newAction(delayedExecutions); + player->addAction(queued); + player->delayAction(delayed, 1.0f); + + player->applyEffect( + std::make_shared(1), DurationType::Permanent); + EffectInstance referencedEffect; + referencedEffect.effect = std::make_shared(EffectType::Invalid); + referencedEffect.id = harness.game().allocateEffectId(); + referencedEffect.subType = static_cast(DurationType::Permanent); + referencedEffect.creatorId = companion->id(); + referencedEffect.objectParameters[0] = companion->id(); + ASSERT_TRUE(harness.game().bindEffectCreator(referencedEffect)); + ASSERT_TRUE(player->restoreEffect(std::move(referencedEffect))); + ASSERT_EQ(1, player->modifiedAttacks()); + ASSERT_EQ(2u, player->effects().size()); + + TestGameModule::setAreaRuntimePath(*player, harness.area().pathfinder()); + player->startStuntMode(); + const auto generation = TestGameModule::savedGraphGeneration(harness.game()); + + ASSERT_EQ(1, harness.switchTo(0)); + + EXPECT_EQ(companion, harness.game().party().player()); + EXPECT_EQ(player, harness.game().party().actualPlayer()); + EXPECT_EQ(companion, harness.game().party().rosterCreature({RosterKind::Npc, 0})); + EXPECT_EQ(generation, TestGameModule::savedGraphGeneration(harness.game())); + EXPECT_EQ(1u, player->actions().size()); + EXPECT_EQ(1u, TestGameModule::delayedActionCount(*player)); + EXPECT_TRUE(TestGameModule::hasAreaRuntimePath(*player)); + EXPECT_TRUE(player->isStuntMode()); + EXPECT_EQ(1, player->modifiedAttacks()); + ASSERT_EQ(2u, player->effects().size()); + EXPECT_EQ(companion, player->effects()[1].boundCreator()); + EXPECT_EQ(companion, player->effects()[1].boundObjectParameter(0)); + + player->update(0.5f); + EXPECT_EQ(1, queuedExecutions); + EXPECT_EQ(0, delayedExecutions); + player->update(0.6f); + EXPECT_EQ(1, delayedExecutions); +} + +TEST_P(PartyControl, sameAreaControlSwitchDoesNotDetachOrDuplicateResidents) { + AreaControlHarness harness(GetParam()); + auto player = harness.player(); + auto companion = harness.companion(); + + ASSERT_EQ(&harness.room(), player->room()); + ASSERT_EQ(&harness.room(), companion->room()); + ASSERT_EQ(1u, harness.room().tenants().count(player.get())); + ASSERT_EQ(1u, harness.room().tenants().count(companion.get())); + + const glm::vec3 outgoingPosition = player->position(); + ASSERT_EQ(1, harness.switchTo(0)); + + const auto &creatures = harness.area().getObjectsByType(ObjectType::Creature); + EXPECT_EQ(1, std::count(creatures.begin(), creatures.end(), player)); + EXPECT_EQ(1, std::count(creatures.begin(), creatures.end(), companion)); + EXPECT_EQ(outgoingPosition, companion->position()); + EXPECT_EQ(&harness.room(), player->room()); + EXPECT_EQ(&harness.room(), companion->room()); + EXPECT_EQ(1u, harness.room().tenants().count(player.get())); + EXPECT_EQ(1u, harness.room().tenants().count(companion.get())); +} + INSTANTIATE_TEST_SUITE_P(Games, PartyControl, testing::Values(GameID::KotOR, GameID::TSL)); diff --git a/test/game/partyroutines.cpp b/test/game/partyroutines.cpp index 8566d1b05..b042fd876 100644 --- a/test/game/partyroutines.cpp +++ b/test/game/partyroutines.cpp @@ -100,6 +100,7 @@ TEST(PartyRoutines, get_party_leader_follows_a_companion_leader_not_the_canonica auto companion = harness.game().newCreature(); ASSERT_NE(pc->id(), companion->id()); + harness.game().party().addAvailableMember(kNpcCompanion, companion); harness.game().party().addMember(kNpcCompanion, companion); harness.game().party().addMember(kNpcPlayer, pc); harness.game().party().setPlayer(pc); diff --git a/test/game/pazaakintegration.cpp b/test/game/pazaakintegration.cpp index 6567412b3..0400cde46 100644 --- a/test/game/pazaakintegration.cpp +++ b/test/game/pazaakintegration.cpp @@ -1819,6 +1819,9 @@ TEST(PazaakGUIResources, MissingKotorOneOptionalControlsUseDevelopmentFallbacks) TEST(PazaakGameLifecycle, ResetAndTechnicalAbortDoNotInventResult) { GameFixture fixture; + testing::NiceMock sceneGraph; + ON_CALL(fixture.engine.sceneModule().graphs(), get(testing::_)) + .WillByDefault(testing::ReturnRef(sceneGraph)); configure(fixture.game); ASSERT_TRUE(fixture.game.playPazaak(0, "", 0, false, fixture.opponent)); fixture.game.abortPazaak(); diff --git a/test/game/reputes.cpp b/test/game/reputes.cpp index 1fe467dd8..319f91f5b 100644 --- a/test/game/reputes.cpp +++ b/test/game/reputes.cpp @@ -614,16 +614,26 @@ TEST(ReputesFac, save_switch_a_b_a_replaces_all_factions_and_pairs) { EXPECT_EQ(77, reputes->getReputation(kBeta, kGamma)); EXPECT_EQ("save_a", reputes->factions()[4].name); } -void reone::game::TestGameModule::deserializeAvailableNpcs(Game &game) { - game.deserializeAvailableNpcs(); +void reone::game::TestGameModule::publishPartyRuntimeState( + Game &game, + Gff &ifoGff, + const std::shared_ptr &ptGff, + const std::shared_ptr &pcGff) { + game.publishPartyRuntimeState( + ifoGff, + ptGff, + pcGff, + SerializedIdentityContext::moduleGraph("test-module")); } void reone::game::TestGameModule::publishPartyRuntimeState( Game &game, Gff &ifoGff, const std::shared_ptr &ptGff, - const std::shared_ptr &pcGff) { - game.publishPartyRuntimeState(ifoGff, ptGff, pcGff); + const std::shared_ptr &pcGff, + const SerializedIdentityContext &identityContext) { + game.publishPartyRuntimeState( + ifoGff, ptGff, pcGff, identityContext); } void reone::game::TestGameModule::deserializeCustomTokens( @@ -802,9 +812,15 @@ std::shared_ptr savedPlayer(std::string tag, uint32_t id, bool primary) { } std::shared_ptr emptyPartyTable(int controlledNpc = -1) { + std::vector> available; + for (int slot = 0; slot <= controlledNpc; ++slot) { + available.push_back( + availableNpc(slot == controlledNpc, slot == controlledNpc)); + } return Gff::Builder() .field(Gff::Field::newInt("PT_CONTROLLED_NP", controlledNpc)) .field(Gff::Field::newByte("PT_NUM_MEMBERS", 0)) + .field(Gff::Field::newList("PT_AVAIL_NPCS", std::move(available))) .build(); } @@ -985,7 +1001,8 @@ TEST(SaveWideAvailableNpcs, a_b_a_replaces_indexed_working_state_records) { findSaveWorking(ResourceId( str(boost::format("availnpc%d") % index), ResType::Utc))) .WillOnce(Return(encodedCreature(std::move(tag)))); - TestGameModule::deserializeAvailableNpcs(fixture.game); + EXPECT_TRUE(fixture.game.party().getAvailableMember( + static_cast(index), true)); }; loadNpc(0, "npc_a"); @@ -1011,7 +1028,7 @@ TEST(SaveWideAvailableNpcs, malformed_optional_record_is_ignored) { findSaveWorking(ResourceId("availnpc0", ResType::Utc))) .WillOnce(Return(Resource {{'b', 'a', 'd'}})); - TestGameModule::deserializeAvailableNpcs(fixture.game); + EXPECT_FALSE(fixture.game.party().getAvailableMember(0, true)); EXPECT_FALSE(fixture.game.party().getAvailableMember(0)); } @@ -1033,9 +1050,10 @@ TEST(SaveWideAvailablePuppets, k2_materializes_available_puppet_resource) { findSaveWorking(ResourceId("availpup0", ResType::Utc))) .WillOnce(Return(encodedCreature("puppet_a"))); - TestGameModule::deserializeAvailableNpcs(fixture.game); + EXPECT_TRUE(fixture.game.party().getAvailablePuppet(0, true)); ASSERT_TRUE(fixture.game.party().getAvailablePuppet(0)); EXPECT_EQ("puppet_a", fixture.game.party().getAvailablePuppet(0)->tag()); + EXPECT_TRUE(fixture.game.party().getAvailablePuppet(0)->isPuppet()); } TEST(SavedPlayerRestoration, primary_module_player_does_not_duplicate_pc_utc) { @@ -1048,7 +1066,7 @@ TEST(SavedPlayerRestoration, primary_module_player_does_not_duplicate_pc_utc) { .Times(AnyNumber()); auto partyTable = emptyPartyTable(); - auto player = savedPlayer("primary", 100, true); + auto player = savedPlayer("", 100, true); auto ifo = Gff::Builder() .field(Gff::Field::newList("Mod_PlayerList", {player})) .build(); @@ -1058,10 +1076,43 @@ TEST(SavedPlayerRestoration, primary_module_player_does_not_duplicate_pc_utc) { ASSERT_TRUE(fixture.game.party().player()); EXPECT_EQ(fixture.game.party().player(), fixture.game.party().actualPlayer()); - EXPECT_EQ(100u, fixture.game.party().player()->id()); + EXPECT_EQ(kObjectTagPlayer, fixture.game.party().player()->tag()); + EXPECT_NE(100u, fixture.game.party().player()->id()); + EXPECT_EQ( + fixture.game.party().player(), + fixture.game.getObjectBySavedId(100)); EXPECT_EQ(1, fixture.game.party().getSize()); } +TEST(SavedPlayerRestoration, autosave_pifo_player_uses_detached_identity) { + Fixture fixture(GameID::KotOR); + EXPECT_CALL(fixture.engine.resourceModule().twoDas(), get("appearance")) + .WillRepeatedly(Return(appearanceTable())); + EXPECT_CALL(fixture.engine.resourceModule().models(), get(_)).Times(AnyNumber()); + EXPECT_CALL(static_cast(fixture.engine.services().game.portraits), + getTextureByAppearance(_)) + .Times(AnyNumber()); + + auto partyTable = emptyPartyTable(); + auto player = savedPlayer("jolee", 777, true); + auto pifo = Gff::Builder() + .field(Gff::Field::newList("Mod_PlayerList", {player})) + .build(); + const auto context = + SerializedIdentityContext::detachedRecord("pifo.ifo"); + + TestGameModule::publishPartyRuntimeState( + fixture.game, *pifo, partyTable, nullptr, context); + + ASSERT_TRUE(fixture.game.party().player()); + EXPECT_EQ("jolee", fixture.game.party().player()->tag()); + EXPECT_NE(777u, fixture.game.party().player()->id()); + EXPECT_FALSE(fixture.game.getObjectBySavedId(777)); + EXPECT_EQ( + fixture.game.party().player()->serializedObjectIdentity(), + std::optional({context, 777})); +} + TEST(SavedPlayerRestoration, controlled_companion_keeps_pc_utc_as_actual_player) { Fixture fixture(GameID::TSL); EXPECT_CALL(fixture.engine.resourceModule().twoDas(), get("appearance")) @@ -1075,6 +1126,8 @@ TEST(SavedPlayerRestoration, controlled_companion_keeps_pc_utc_as_actual_player) .field(Gff::Field::newInt("PT_CONTROLLED_NP", 0)) .field(Gff::Field::newByte("PT_NUM_MEMBERS", 1)) .field(Gff::Field::newList("PT_MEMBERS", {member(0, true)})) + .field(Gff::Field::newList( + "PT_AVAIL_NPCS", {availableNpc(true, true)})) .build(); auto pc = Gff::Builder() .field(Gff::Field::newCExoString("Tag", "actual_pc")) @@ -1094,7 +1147,11 @@ TEST(SavedPlayerRestoration, controlled_companion_keeps_pc_utc_as_actual_player) ASSERT_TRUE(fixture.game.party().player()); ASSERT_TRUE(fixture.game.party().actualPlayer()); EXPECT_NE(fixture.game.party().player(), fixture.game.party().actualPlayer()); - EXPECT_EQ(101u, fixture.game.party().player()->id()); + EXPECT_EQ("controlled", fixture.game.party().player()->tag()); + EXPECT_NE(101u, fixture.game.party().player()->id()); + EXPECT_EQ( + fixture.game.party().player(), + fixture.game.getObjectBySavedId(101)); EXPECT_EQ(fixture.game.party().player(), fixture.game.party().getLeader()); EXPECT_EQ("actual_pc", fixture.game.party().actualPlayer()->tag()); EXPECT_EQ(fixture.game.party().actualPlayer(), @@ -1102,6 +1159,45 @@ TEST(SavedPlayerRestoration, controlled_companion_keeps_pc_utc_as_actual_player) EXPECT_EQ(2, fixture.game.party().getSize()); } +TEST(SavedPlayerRestoration, autosave_pifo_controlled_companion_keeps_authored_tag) { + Fixture fixture(GameID::TSL); + EXPECT_CALL(fixture.engine.resourceModule().twoDas(), get("appearance")) + .WillRepeatedly(Return(appearanceTable())); + EXPECT_CALL(fixture.engine.resourceModule().models(), get(_)).Times(AnyNumber()); + EXPECT_CALL(static_cast(fixture.engine.services().game.portraits), + getTextureByAppearance(_)) + .Times(AnyNumber()); + + auto partyTable = Gff::Builder() + .field(Gff::Field::newInt("PT_CONTROLLED_NP", 0)) + .field(Gff::Field::newByte("PT_NUM_MEMBERS", 1)) + .field(Gff::Field::newList( + "PT_MEMBERS", {member(0, true)})) + .field(Gff::Field::newList( + "PT_AVAIL_NPCS", {availableNpc(true, true)})) + .build(); + auto canonical = savedPlayer("canonical_pc", 900, true); + auto controlled = savedPlayer("jolee", 777, true); + auto pifo = Gff::Builder() + .field(Gff::Field::newList( + "Mod_PlayerList", {controlled})) + .build(); + const auto context = + SerializedIdentityContext::detachedRecord("pifo.ifo"); + + TestGameModule::publishPartyRuntimeState( + fixture.game, *pifo, partyTable, canonical, context); + + ASSERT_TRUE(fixture.game.party().player()); + ASSERT_TRUE(fixture.game.party().actualPlayer()); + EXPECT_EQ("jolee", fixture.game.party().player()->tag()); + EXPECT_EQ("canonical_pc", fixture.game.party().actualPlayer()->tag()); + EXPECT_NE(fixture.game.party().player(), fixture.game.party().actualPlayer()); + EXPECT_EQ(fixture.game.party().player(), + fixture.game.party().rosterCreature({RosterKind::Npc, 0})); + EXPECT_FALSE(fixture.game.getObjectBySavedId(777)); +} + TEST(SavedPlayerRestoration, k2_zero_member_controlled_npc_uses_partytable_despite_primary_flag) { Fixture fixture(GameID::TSL); @@ -1128,10 +1224,18 @@ TEST(SavedPlayerRestoration, ASSERT_TRUE(fixture.game.party().player()); ASSERT_TRUE(fixture.game.party().actualPlayer()); EXPECT_NE(fixture.game.party().player(), fixture.game.party().actualPlayer()); - EXPECT_EQ(kObjectTagPlayer, fixture.game.party().player()->tag()); - EXPECT_EQ(330u, fixture.game.party().player()->id()); + EXPECT_EQ("t3m4", fixture.game.party().player()->tag()); + EXPECT_NE(330u, fixture.game.party().player()->id()); + EXPECT_EQ( + fixture.game.party().player(), + fixture.game.getObjectBySavedId(330)); EXPECT_EQ("canonical_pc", fixture.game.party().actualPlayer()->tag()); - EXPECT_EQ(2147483646u, fixture.game.party().actualPlayer()->id()); + EXPECT_NE(2147483646u, fixture.game.party().actualPlayer()->id()); + EXPECT_EQ( + fixture.game.party().actualPlayer()->serializedObjectIdentity(), + std::optional({ + SerializedIdentityContext::detachedRecord("pc.utc"), + 2147483646u})); EXPECT_EQ(fixture.game.party().player(), fixture.game.party().getLeader()); EXPECT_EQ(fixture.game.party().player(), fixture.game.party().getMemberByNPC(8)); diff --git a/test/game/rostermaterialization.cpp b/test/game/rostermaterialization.cpp new file mode 100644 index 000000000..072a43d63 --- /dev/null +++ b/test/game/rostermaterialization.cpp @@ -0,0 +1,1446 @@ +/* + * Copyright (c) 2026 The reone project contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include + +#include "../fixtures/engine.h" +#include "../fixtures/game.h" + +#include "reone/game/game.h" +#include "reone/game/action/wait.h" +#include "reone/game/effect.h" +#include "reone/game/d20/class.h" +#include "reone/game/d20/classes.h" +#include "reone/game/modulesnapshot.h" +#include "reone/game/object/area.h" +#include "reone/game/object/creature.h" +#include "reone/game/object/item.h" +#include "reone/game/party.h" +#include "reone/game/script/routines.h" +#include "reone/resource/2da.h" +#include "reone/resource/format/gffreader.h" +#include "reone/resource/format/gffwriter.h" +#include "reone/resource/gff.h" +#include "reone/resource/saveworkingstate.h" +#include "reone/script/executioncontext.h" +#include "reone/script/variable.h" +#include "reone/system/stream/memoryinput.h" + +using namespace reone; +using namespace reone::game; +using namespace reone::resource; +using namespace reone::script; +using namespace testing; + +namespace { + +std::shared_ptr creatureRecord( + std::string tag, + std::string name = "Companion") { + return Gff::Builder() + .field(Gff::Field::newCExoString("Tag", std::move(tag))) + .field(Gff::Field::newResRef("TemplateResRef", "p_companion")) + .field(Gff::Field::newCExoLocString("FirstName", -1, std::move(name))) + .field(Gff::Field::newCExoLocString("LastName", -1, "")) + .field(Gff::Field::newDword("Appearance_Type", 1)) + .field(Gff::Field::newWord("PortraitId", 11)) + .field(Gff::Field::newByte("Gender", 0)) + .field(Gff::Field::newByte("Race", 6)) + .field(Gff::Field::newWord("SoundSetFile", 21)) + .build(); +} + +ByteBuffer encodeUtc(const Gff &gff) { + return GffWriter(GffFileFormat::v32("UTC "), gff).toBytes(); +} + +std::shared_ptr creatureAppearanceTable() { + TwoDA::Builder builder; + builder.columns({ + "modeltype", "walkdist", "rundist", "footsteptype", + "envmap", "race", "racetex"}); + builder.row({"S", "1", "1", "-1", "", "", ""}); + builder.row({"S", "1", "1", "-1", "", "", ""}); + return std::shared_ptr(builder.build()); +} + +std::shared_ptr itemBaseTable() { + TwoDA::Builder builder; + builder.columns({"equipableslots", "itemclass", "ammunitiontype"}); + builder.row({"2", "i_test", "0"}); + return std::shared_ptr(builder.build()); +} + +std::shared_ptr itemRecord(std::string tag) { + return Gff::Builder() + .field(Gff::Field::newCExoString("Tag", std::move(tag))) + .field(Gff::Field::newInt("BaseItem", 0)) + .field(Gff::Field::newWord("StackSize", 1)) + .build(); +} + +std::shared_ptr decodeGff(const ByteBuffer &bytes) { + ByteBuffer copy(bytes); + MemoryInputStream stream(copy); + GffReader reader(stream); + reader.load(); + return reader.root(); +} + +class RosterHarness { +public: + explicit RosterHarness(GameID gameId = GameID::TSL) : + game(gameId, "", testEngine().options(), testEngine().services(), console), + routines(gameId, &game, &testEngine().services()) { + // Object tests share this mock and leave narrow saturated lookups + // behind. Roster fixtures establish their own resource contract. + testing::Mock::VerifyAndClear( + &testEngine().resourceModule().gffs()); + ON_CALL(testEngine().sceneModule().graphs(), get(_)) + .WillByDefault(ReturnRef(sharedSceneGraph())); + EXPECT_CALL(testEngine().resourceModule().twoDas(), get("appearance")) + .Times(AnyNumber()) + .WillRepeatedly(Return(creatureAppearanceTable())); + EXPECT_CALL(testEngine().resourceModule().twoDas(), get("soundset")) + .Times(AnyNumber()) + .WillRepeatedly(Return(nullptr)); + EXPECT_CALL(testEngine().resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(itemBaseTable())); + EXPECT_CALL(testEngine().resourceModule().textures(), get(_, _)) + .Times(AnyNumber()); + EXPECT_CALL( + testEngine().resourceModule().director(), + committedSaveWorkingState()) + .Times(AnyNumber()) + .WillRepeatedly(Return(nullptr)); + EXPECT_CALL( + testEngine().resourceModule().director(), + adoptSaveWorkingState(_)) + .Times(AnyNumber()); + routines.init(); + } + + ~RosterHarness() { + // testEngine() is process-global. Remove callbacks which capture this + // harness's per-test save state before that state disappears. + testing::Mock::VerifyAndClear( + &testEngine().resourceModule().director()); + } + + static NiceMock &sharedSceneGraph() { + static NiceMock graph; + return graph; + } + + std::shared_ptr creature(std::string tag) { + auto result = game.newCreature(); + result->setTag(std::move(tag)); + return result; + } + + Variable call(const std::string &name, std::vector args) { + ExecutionContext execution; + execution.routines = &routines; + auto &routine = routines.get(routines.getIndexByName(name)); + return routine.invoke(args, execution); + } + + StubConsole console; + Game game; + Routines routines; +}; + +class PartySelectionRuntimeHarness { +public: + explicit PartySelectionRuntimeHarness(GameID gameId) : + roster(gameId) { + player = roster.creature("player"); + area = roster.game.newArea(); + TestGameModule::configureModuleSnapshot( + roster.game, area, player, "party_select", "party_select"); + area->add(player); + } + + std::shared_ptr addSelected(int npc, std::string tag) { + auto creature = roster.creature(std::move(tag)); + EXPECT_TRUE(roster.game.party().addAvailableMember(npc, creature)); + EXPECT_TRUE(roster.game.party().addMember(npc, creature)); + area->add(creature); + return creature; + } + + void serveDetached(int npc, std::string tag) { + auto bytes = encodeUtc(*creatureRecord(std::move(tag))); + EXPECT_CALL( + testEngine().resourceModule().director(), + findSaveWorking(ResourceId( + "availnpc" + std::to_string(npc), ResType::Utc))) + .WillOnce(Return(Resource {std::move(bytes)})); + } + + size_t partyOccurrences(int npc) { + return std::count_if( + roster.game.party().members().begin(), + roster.game.party().members().end(), + [npc](const Party::Member &member) { + return member.npc == npc; + }); + } + + size_t areaOccurrences(const Creature &creature) const { + return std::count_if( + area->objects().begin(), area->objects().end(), + [&creature](const auto &object) { + return object.get() == &creature; + }); + } + + RosterHarness roster; + std::shared_ptr area; + std::shared_ptr player; +}; + +class PartySelectionRuntime : public TestWithParam {}; + +} // namespace + +TEST_P(PartySelectionRuntime, coherent_unchanged_selection_is_a_noop) { + PartySelectionRuntimeHarness harness(GetParam()); + auto companion = harness.addSelected(0, "companion"); + companion->setPosition({91.0f, 72.0f, 0.0f}); + const auto position = companion->position(); + const auto registrySize = TestGameModule::objectRegistrySize( + harness.roster.game); + + ASSERT_TRUE(harness.roster.game.isPartySelectionRealized({0})); + ASSERT_TRUE(harness.roster.game.reconcilePartySelection({0})); + + EXPECT_EQ(companion, harness.roster.game.party().getMemberByNPC(0)); + EXPECT_EQ(companion, harness.roster.game.party().rosterCreature( + {RosterKind::Npc, 0})); + EXPECT_EQ(position, companion->position()); + EXPECT_EQ(registrySize, TestGameModule::objectRegistrySize( + harness.roster.game)); + EXPECT_EQ(1u, harness.partyOccurrences(0)); + EXPECT_EQ(1u, harness.areaOccurrences(*companion)); +} + +TEST_P(PartySelectionRuntime, unchanged_selection_repairs_missing_area_residency) { + PartySelectionRuntimeHarness harness(GetParam()); + auto companion = harness.addSelected(0, "companion"); + ASSERT_TRUE(harness.area->releaseObject(companion)); + ASSERT_FALSE(harness.area->isObjectResident(*companion)); + + ASSERT_FALSE(harness.roster.game.isPartySelectionRealized({0})); + ASSERT_TRUE(harness.roster.game.reconcilePartySelection({0})); + + EXPECT_EQ(companion, harness.roster.game.party().getMemberByNPC(0)); + EXPECT_EQ(companion, harness.roster.game.party().rosterCreature( + {RosterKind::Npc, 0})); + EXPECT_TRUE(harness.area->isObjectResident(*companion)); + EXPECT_EQ(1u, harness.partyOccurrences(0)); + EXPECT_EQ(1u, harness.areaOccurrences(*companion)); +} + +TEST(PartySelectionRuntimeTSL, unchanged_selection_replaces_pending_representations) { + PartySelectionRuntimeHarness harness(GameID::TSL); + auto oldA = harness.addSelected(0, "companion_a"); + auto oldB = harness.addSelected(1, "companion_b"); + harness.serveDetached(0, "companion_a"); + harness.serveDetached(1, "companion_b"); + harness.area->destroyObject(*oldA); + harness.area->destroyObject(*oldB); + + ASSERT_FALSE(harness.roster.game.isPartySelectionRealized({0, 1})); + ASSERT_TRUE(harness.roster.game.reconcilePartySelection({0, 1})); + auto replacementA = harness.roster.game.party().getMemberByNPC(0); + auto replacementB = harness.roster.game.party().getMemberByNPC(1); + ASSERT_TRUE(replacementA); + ASSERT_TRUE(replacementB); + EXPECT_NE(oldA, replacementA); + EXPECT_NE(oldB, replacementB); + EXPECT_TRUE(harness.area->isObjectPendingDestruction(*oldA)); + EXPECT_TRUE(harness.area->isObjectPendingDestruction(*oldB)); + EXPECT_TRUE(harness.area->isObjectResident(*replacementA)); + EXPECT_TRUE(harness.area->isObjectResident(*replacementB)); + + harness.area->update(0.0f); + + EXPECT_FALSE(harness.roster.game.isRuntimeObjectLive(*oldA)); + EXPECT_FALSE(harness.roster.game.isRuntimeObjectLive(*oldB)); + EXPECT_TRUE(harness.roster.game.isRuntimeObjectLive(*replacementA)); + EXPECT_TRUE(harness.roster.game.isRuntimeObjectLive(*replacementB)); + EXPECT_EQ(replacementA, harness.roster.game.party().rosterCreature( + {RosterKind::Npc, 0})); + EXPECT_EQ(replacementB, harness.roster.game.party().rosterCreature( + {RosterKind::Npc, 1})); + EXPECT_EQ(1u, harness.partyOccurrences(0)); + EXPECT_EQ(1u, harness.partyOccurrences(1)); + EXPECT_EQ(1u, harness.areaOccurrences(*replacementA)); + EXPECT_EQ(1u, harness.areaOccurrences(*replacementB)); + EXPECT_TRUE(harness.roster.game.isPartySelectionRealized({0, 1})); +} + +TEST(PartySelectionRuntimeTSL, destruction_before_confirmation_materializes_selection) { + PartySelectionRuntimeHarness harness(GameID::TSL); + auto obsolete = harness.addSelected(0, "companion"); + harness.area->destroyObject(*obsolete); + harness.area->update(0.0f); + ASSERT_FALSE(harness.roster.game.party().rosterCreature( + {RosterKind::Npc, 0})); + harness.serveDetached(0, "companion"); + + ASSERT_TRUE(harness.roster.game.reconcilePartySelection({0})); + + auto replacement = harness.roster.game.party().getMemberByNPC(0); + ASSERT_TRUE(replacement); + EXPECT_NE(obsolete, replacement); + EXPECT_TRUE(harness.roster.game.isRuntimeObjectLive(*replacement)); + EXPECT_TRUE(harness.area->isObjectResident(*replacement)); + EXPECT_EQ(1u, harness.partyOccurrences(0)); + EXPECT_EQ(1u, harness.areaOccurrences(*replacement)); +} + +TEST(PartySelectionRuntimeTSL, missing_binding_is_materialized_for_unchanged_request) { + PartySelectionRuntimeHarness harness(GameID::TSL); + auto obsolete = harness.addSelected(0, "companion"); + ASSERT_TRUE(harness.roster.game.party().clearRosterCreature( + {RosterKind::Npc, 0}, obsolete.get())); + harness.serveDetached(0, "companion"); + + ASSERT_TRUE(harness.roster.game.reconcilePartySelection({0})); + + auto replacement = harness.roster.game.party().getMemberByNPC(0); + ASSERT_TRUE(replacement); + EXPECT_NE(obsolete, replacement); + EXPECT_TRUE(harness.area->isObjectResident(*replacement)); + EXPECT_EQ(1u, harness.partyOccurrences(0)); +} + +TEST(PartySelectionRuntimeTSL, changed_selection_uses_normal_reconciliation) { + PartySelectionRuntimeHarness harness(GameID::TSL); + auto oldMember = harness.addSelected(0, "old_companion"); + auto replacement = harness.roster.creature("new_companion"); + ASSERT_TRUE(harness.roster.game.party().addAvailableMember(1, replacement)); + + ASSERT_TRUE(harness.roster.game.reconcilePartySelection({1})); + + EXPECT_FALSE(harness.roster.game.party().isMember(0)); + EXPECT_EQ(replacement, harness.roster.game.party().getMemberByNPC(1)); + EXPECT_FALSE(harness.area->isObjectResident(*oldMember)); + EXPECT_TRUE(harness.area->isObjectResident(*replacement)); + EXPECT_TRUE(harness.roster.game.isRuntimeObjectLive(*oldMember)); + EXPECT_EQ(1u, harness.partyOccurrences(1)); +} + +INSTANTIATE_TEST_SUITE_P( + K1AndK2, + PartySelectionRuntime, + Values(GameID::KotOR, GameID::TSL)); + +TEST(RosterBinding, same_tag_npc_puppet_and_module_creature_coexist) { + RosterHarness harness; + auto npc = harness.creature("remote"); + auto puppet = harness.creature("remote"); + ASSERT_TRUE(harness.game.party().addAvailableMember(1, npc)); + ASSERT_TRUE(harness.game.party().addAvailablePuppet(0, puppet)); + ASSERT_TRUE(harness.game.party().addMember(1, npc)); + + auto git = creatureRecord("remote", "Module Remote"); + auto context = SerializedIdentityContext::templateResource("test-area"); + auto moduleCreature = harness.game.newCreature(*git, context); + moduleCreature->deserialize(*git, context); + + EXPECT_EQ(npc, harness.game.party().rosterCreature({RosterKind::Npc, 1})); + EXPECT_EQ(npc, harness.game.party().getMemberByNPC(1)); + EXPECT_EQ( + puppet, + harness.game.party().rosterCreature({RosterKind::Puppet, 0})); + EXPECT_FALSE(harness.game.party().rosterIdentity(*moduleCreature)); + EXPECT_NE(npc, puppet); + EXPECT_NE(npc, moduleCreature); + EXPECT_NE(puppet, moduleCreature); +} + +TEST(RosterBinding, same_tag_module_double_cannot_steal_upgraded_companion) { + RosterHarness harness; + auto companion = harness.creature("g0t0"); + companion->setLocalNumber(7, 137); + ASSERT_TRUE(harness.game.party().addAvailableMember(3, companion)); + + auto doubleCreature = harness.creature("g0t0"); + doubleCreature->setLocalNumber(7, 12); + + EXPECT_EQ(companion, harness.game.party().getAvailableMember(3)); + EXPECT_EQ(137, companion->getLocalNumber(7)); + EXPECT_FALSE(harness.game.party().rosterIdentity(*doubleCreature)); +} + +TEST(RosterBinding, explicit_binding_uses_slot_not_tag_or_object_number) { + RosterHarness harness; + Party::PersistedState state; + state.npcAvailable[4] = true; + harness.game.party().setPersistedState(state); + auto first = harness.creature("first"); + auto exact = harness.creature("unrelated_tag"); + ASSERT_TRUE(harness.game.party().bindRosterCreature( + {RosterKind::Npc, 4}, first)); + + ASSERT_TRUE(harness.game.party().bindRosterCreature( + {RosterKind::Npc, 4}, exact)); + + EXPECT_EQ(exact, harness.game.party().getAvailableMember(4)); + EXPECT_FALSE(harness.game.party().rosterIdentity(*first)); + EXPECT_EQ( + (RosterIdentity {RosterKind::Npc, 4}), + *harness.game.party().rosterIdentity(*exact)); +} + +TEST(RosterBinding, set_available_npc_id_binds_the_exact_script_object) { + RosterHarness harness; + auto &director = testEngine().resourceModule().director(); + auto committed = std::make_shared(); + EXPECT_CALL(director, committedSaveWorkingState()) + .WillOnce(Return(committed)); + EXPECT_CALL(director, adoptSaveWorkingState(_)) + .WillOnce(Invoke([&committed](auto state) { committed = std::move(state); })); + Party::PersistedState state; + state.npcAvailable[4] = true; + harness.game.party().setPersistedState(state); + auto first = harness.creature("same_tag"); + auto exact = harness.creature("same_tag"); + first->setAppearance(111); + exact->setAppearance(222); + ASSERT_TRUE(harness.game.party().bindRosterCreature( + {RosterKind::Npc, 4}, first)); + + harness.call( + "SetAvailableNPCId", + {Variable::ofInt(4), Variable::ofObject(exact->id())}); + + EXPECT_EQ(exact, harness.game.party().getAvailableMember(4)); + EXPECT_FALSE(harness.game.party().rosterIdentity(*first)); + + harness.game.saveNpcState(4); + auto saved = committed->find({"availnpc4", ResType::Utc}); + ASSERT_TRUE(saved); + EXPECT_EQ(222u, decodeGff(saved->data)->getUint("Appearance_Type")); +} + +TEST(RosterBinding, set_available_npc_id_can_explicitly_clear_binding) { + RosterHarness harness; + auto creature = harness.creature("companion"); + ASSERT_TRUE(harness.game.party().addAvailableMember(4, creature)); + + harness.call( + "SetAvailableNPCId", + {Variable::ofInt(4), Variable::ofObject(kObjectInvalid)}); + + EXPECT_TRUE(harness.game.party().isMemberAvailable(4)); + EXPECT_FALSE(harness.game.party().getAvailableMember(4)); + EXPECT_FALSE(harness.game.party().rosterIdentity(*creature)); +} + +TEST(RosterBinding, one_runtime_creature_cannot_bind_two_logical_slots) { + RosterHarness harness; + Party::PersistedState state; + state.npcAvailable[0] = true; + state.npcAvailable[1] = true; + harness.game.party().setPersistedState(state); + auto creature = harness.creature("shared"); + + ASSERT_TRUE(harness.game.party().bindRosterCreature( + {RosterKind::Npc, 0}, creature)); + EXPECT_FALSE(harness.game.party().bindRosterCreature( + {RosterKind::Npc, 1}, creature)); + EXPECT_FALSE(harness.game.party().rosterCreature({RosterKind::Npc, 1})); +} + +TEST(RosterBinding, failed_runtime_binding_does_not_publish_availability) { + RosterHarness harness; + auto creature = harness.creature("companion"); + ASSERT_TRUE(harness.game.party().addAvailableMember(0, creature)); + + EXPECT_FALSE(harness.game.party().addAvailableMember(1, creature)); + EXPECT_FALSE(harness.game.party().isMemberAvailable(1)); + EXPECT_EQ(creature, harness.game.party().getAvailableMember(0)); +} + +TEST(RosterBinding, puppet_rebinding_transfers_the_runtime_puppet_role) { + RosterHarness harness; + auto first = harness.creature("remote"); + auto replacement = harness.creature("remote"); + ASSERT_TRUE(harness.game.party().addAvailablePuppet(0, first)); + ASSERT_TRUE(first->isPuppet()); + + ASSERT_TRUE(harness.game.party().bindRosterCreature( + {RosterKind::Puppet, 0}, replacement)); + + EXPECT_FALSE(first->isPuppet()); + EXPECT_TRUE(replacement->isPuppet()); + EXPECT_FALSE(harness.game.party().rosterIdentity(*first)); + EXPECT_EQ( + (RosterIdentity {RosterKind::Puppet, 0}), + *harness.game.party().rosterIdentity(*replacement)); +} + +TEST(RosterBinding, canonical_player_cannot_be_claimed_by_roster_slot) { + RosterHarness harness; + Party::PersistedState state; + state.npcAvailable[0] = true; + harness.game.party().setPersistedState(state); + auto player = harness.creature("player"); + harness.game.party().setPlayer(player); + harness.game.party().setActualPlayer(player); + + EXPECT_FALSE(harness.game.party().bindRosterCreature( + {RosterKind::Npc, 0}, player)); + EXPECT_FALSE(harness.game.party().rosterIdentity(*player)); +} + +TEST(RosterBinding, availability_is_partytable_state_not_binding_presence) { + RosterHarness harness; + auto creature = harness.creature("companion"); + ASSERT_TRUE(harness.game.party().addAvailableMember(0, creature)); + ASSERT_TRUE(harness.game.party().isMemberAvailable(0)); + ASSERT_TRUE(harness.game.party().clearRosterCreature( + {RosterKind::Npc, 0}, creature.get())); + + EXPECT_TRUE(harness.game.party().isMemberAvailable(0)); + EXPECT_TRUE(harness.game.party().persistedState().npcAvailable[0]); + EXPECT_FALSE(harness.game.party().getAvailableMember(0)); + + EXPECT_TRUE(harness.game.party().removeAvailableMember(0)); + EXPECT_FALSE(harness.game.party().isMemberAvailable(0)); + EXPECT_FALSE(harness.game.party().persistedState().npcAvailable[0]); +} + +TEST(RosterBinding, removing_availability_preserves_bound_runtime_representation) { + RosterHarness harness; + auto area = harness.game.newArea(); + auto creature = harness.creature("companion"); + area->add(creature); + Party::PersistedState state; + state.npcAvailable[0] = true; + harness.game.party().setPersistedState(state); + ASSERT_TRUE(harness.game.party().bindRosterCreature( + {RosterKind::Npc, 0}, creature)); + ASSERT_TRUE(harness.game.isRuntimeObjectLive(*creature)); + + ASSERT_TRUE(harness.game.party().removeAvailableMember(0)); + + EXPECT_FALSE(harness.game.party().isMemberAvailable(0)); + EXPECT_FALSE(harness.game.party().getAvailableMember(0)); + EXPECT_EQ( + creature, + harness.game.party().rosterCreature({RosterKind::Npc, 0})); + EXPECT_TRUE(harness.game.isRuntimeObjectLive(*creature)); + EXPECT_EQ(creature, harness.game.getObjectById(creature->id())); + const auto &areaCreatures = area->getObjectsByType(ObjectType::Creature); + EXPECT_NE( + areaCreatures.end(), + std::find(areaCreatures.begin(), areaCreatures.end(), creature)); + + EXPECT_TRUE(harness.game.killRosterCreature({RosterKind::Npc, 0})); + EXPECT_FALSE(harness.game.party().rosterCreature({RosterKind::Npc, 0})); + EXPECT_FALSE(harness.game.isRuntimeObjectLive(*creature)); + EXPECT_FALSE(harness.game.getObjectById(creature->id())); + EXPECT_FALSE(harness.game.party().isMemberAvailable(0)); +} + +TEST(RosterBinding, removing_puppet_availability_preserves_binding_until_killed) { + RosterHarness harness; + auto puppet = harness.creature("remote"); + ASSERT_TRUE(harness.game.party().addAvailablePuppet(0, puppet)); + + ASSERT_TRUE(harness.game.party().setRosterAvailable( + {RosterKind::Puppet, 0}, false)); + + EXPECT_FALSE(harness.game.party().isRosterAvailable( + {RosterKind::Puppet, 0})); + EXPECT_EQ( + puppet, + harness.game.party().rosterCreature({RosterKind::Puppet, 0})); + EXPECT_TRUE(harness.game.isRuntimeObjectLive(*puppet)); + + EXPECT_TRUE(harness.game.killRosterCreature({RosterKind::Puppet, 0})); + EXPECT_FALSE(harness.game.party().rosterCreature( + {RosterKind::Puppet, 0})); + EXPECT_FALSE(harness.game.isRuntimeObjectLive(*puppet)); +} + +TEST(RosterBinding, removing_availability_does_not_remove_active_membership) { + RosterHarness harness; + auto creature = harness.creature("active_companion"); + ASSERT_TRUE(harness.game.party().addAvailableMember(0, creature)); + ASSERT_TRUE(harness.game.party().addMember(0, creature)); + + ASSERT_TRUE(harness.game.party().removeAvailableMember(0)); + + EXPECT_FALSE(harness.game.party().isMemberAvailable(0)); + EXPECT_TRUE(harness.game.party().isMember(0)); + EXPECT_EQ(creature, harness.game.party().getMemberByNPC(0)); + EXPECT_EQ( + creature, + harness.game.party().rosterCreature({RosterKind::Npc, 0})); + EXPECT_TRUE(harness.game.isRuntimeObjectLive(*creature)); +} + +TEST(RosterBinding, npc_selectability_is_distinct_persisted_partytable_state) { + RosterHarness harness; + + EXPECT_EQ( + -1, + harness.call("GetNPCSelectability", {Variable::ofInt(4)}).intValue); + + ASSERT_TRUE(harness.game.party().setRosterAvailable( + {RosterKind::Npc, 4}, true)); + EXPECT_EQ( + 1, + harness.call("GetNPCSelectability", {Variable::ofInt(4)}).intValue); + + harness.call( + "SetNPCSelectability", + {Variable::ofInt(4), Variable::ofInt(0)}); + + EXPECT_TRUE(harness.game.party().isMemberAvailable(4)); + EXPECT_FALSE(harness.game.party().isRosterSelectable( + {RosterKind::Npc, 4})); + EXPECT_EQ( + 0, + harness.call("GetNPCSelectability", {Variable::ofInt(4)}).intValue); +} + +TEST(RosterBinding, loading_partytable_state_clears_transient_object_bindings) { + RosterHarness harness; + auto old = harness.creature("old_runtime"); + ASSERT_TRUE(harness.game.party().addAvailableMember(0, old)); + Party::PersistedState loaded; + loaded.npcAvailable[0] = true; + + harness.game.party().loadPersistedState(loaded); + + EXPECT_TRUE(harness.game.party().isMemberAvailable(0)); + EXPECT_FALSE(harness.game.party().getAvailableMember(0)); + EXPECT_FALSE(harness.game.party().rosterIdentity(*old)); +} + +TEST(RosterBinding, add_member_binds_the_exact_available_creature) { + RosterHarness harness; + auto stored = harness.creature("stored"); + auto selected = harness.creature("selected"); + ASSERT_TRUE(harness.game.party().addAvailableMember(2, stored)); + + ASSERT_TRUE(harness.game.party().addMember(2, selected)); + + EXPECT_EQ(selected, harness.game.party().getMemberByNPC(2)); + EXPECT_EQ(selected, harness.game.party().getAvailableMember(2)); + EXPECT_FALSE(harness.game.party().rosterIdentity(*stored)); +} + +TEST(RosterBinding, add_party_member_routine_publishes_membership_and_binding) { + RosterHarness harness; + auto detached = harness.creature("detached"); + auto moduleCreature = harness.creature("module"); + ASSERT_TRUE(harness.game.party().addAvailableMember(2, detached)); + + auto result = harness.call( + "AddPartyMember", + {Variable::ofInt(2), Variable::ofObject(moduleCreature->id())}); + + EXPECT_EQ(1, result.intValue); + EXPECT_EQ(moduleCreature, harness.game.party().getMemberByNPC(2)); + EXPECT_EQ(moduleCreature, harness.game.party().getAvailableMember(2)); +} + +TEST(RosterBinding, add_member_rejects_an_unavailable_slot) { + RosterHarness harness; + auto creature = harness.creature("unavailable"); + + EXPECT_FALSE(harness.game.party().addMember(0, creature)); + EXPECT_FALSE(harness.game.party().isMember(0)); + EXPECT_FALSE(harness.game.party().rosterIdentity(*creature)); +} + +TEST(RosterBinding, controlled_slot_binding_keeps_player_view_coherent) { + RosterHarness harness; + Party::PersistedState state; + state.controlledNpc = 2; + state.npcAvailable[2] = true; + harness.game.party().setPersistedState(state); + auto first = harness.creature("controlled_first"); + auto replacement = harness.creature("controlled_replacement"); + harness.game.party().setPlayer(first); + ASSERT_TRUE(harness.game.party().bindRosterCreature( + {RosterKind::Npc, 2}, first)); + + ASSERT_TRUE(harness.game.party().bindRosterCreature( + {RosterKind::Npc, 2}, replacement)); + + EXPECT_EQ(replacement, harness.game.party().player()); + EXPECT_EQ( + replacement, + harness.game.party().rosterCreature({RosterKind::Npc, 2})); +} + +TEST(RosterBinding, removing_active_member_preserves_availability_and_binding) { + RosterHarness harness; + auto creature = harness.creature("companion"); + ASSERT_TRUE(harness.game.party().addAvailableMember(0, creature)); + ASSERT_TRUE(harness.game.party().addMember(0, creature)); + + ASSERT_TRUE(harness.game.party().removeMember(0)); + + EXPECT_FALSE(harness.game.party().isMember(0)); + EXPECT_TRUE(harness.game.party().isMemberAvailable(0)); + EXPECT_EQ(creature, harness.game.party().getAvailableMember(0)); +} + +TEST(RosterBinding, controlled_member_cannot_be_removed_as_a_follower) { + RosterHarness harness; + auto canonical = harness.creature("player"); + auto controlled = harness.creature("controlled"); + harness.game.party().setPlayer(canonical); + harness.game.party().setActualPlayer(canonical); + ASSERT_TRUE(harness.game.party().addAvailableMember(2, controlled)); + harness.game.party().setControlledMember(2, controlled); + + EXPECT_FALSE(harness.game.party().removeMember(2)); + EXPECT_TRUE(harness.game.party().isMember(2)); + EXPECT_EQ(controlled, harness.game.party().player()); + EXPECT_EQ(controlled, harness.game.party().getAvailableMember(2)); +} + +TEST(RosterBinding, controlling_active_companion_retires_its_assigned_puppet) { + RosterHarness harness; + auto canonical = harness.creature("player"); + auto controlled = harness.creature("controlled"); + auto puppet = harness.creature("remote"); + harness.game.party().setPlayer(canonical); + harness.game.party().setActualPlayer(canonical); + ASSERT_TRUE(harness.game.party().addMember(kNpcPlayer, canonical)); + ASSERT_TRUE(harness.game.party().addAvailableMember(2, controlled)); + ASSERT_TRUE(harness.game.party().addAvailablePuppet(0, puppet)); + ASSERT_TRUE(harness.game.party().assignPuppet(0, 2)); + ASSERT_TRUE(harness.game.party().addMember(2, controlled)); + ASSERT_TRUE(harness.game.party().isPuppet(0)); + + harness.game.party().setControlledMember(2, controlled); + + EXPECT_EQ(controlled, harness.game.party().player()); + EXPECT_EQ(2, harness.game.party().controlledNpc()); + EXPECT_TRUE(harness.game.party().isMember(2)); + EXPECT_FALSE(harness.game.party().isPuppet(0)); + EXPECT_FALSE(harness.game.party().getAvailablePuppet(0)); + EXPECT_TRUE(harness.game.party().isRosterAvailable({RosterKind::Puppet, 0})); +} + +TEST(RosterBinding, runtime_destruction_clears_binding_not_availability) { + RosterHarness harness; + auto creature = harness.creature("companion"); + ASSERT_TRUE(harness.game.party().addAvailableMember(0, creature)); + ASSERT_TRUE(harness.game.party().addMember(0, creature)); + + harness.game.destroyRuntimeObjectGraph(creature); + + EXPECT_FALSE(harness.game.party().rosterCreature({RosterKind::Npc, 0})); + EXPECT_FALSE(harness.game.party().isMember(0)); + EXPECT_TRUE(harness.game.party().isMemberAvailable(0)); + EXPECT_FALSE(harness.game.getObjectById(creature->id())); +} + +TEST(RosterBinding, puppet_kill_clears_binding_and_active_role_not_availability) { + RosterHarness harness; + auto puppet = harness.creature("remote"); + ASSERT_TRUE(harness.game.party().addAvailablePuppet(0, puppet)); + ASSERT_TRUE(harness.game.party().addPuppet(0, puppet)); + + EXPECT_TRUE(harness.game.killRosterCreature({RosterKind::Puppet, 0})); + + EXPECT_FALSE(harness.game.party().rosterCreature({RosterKind::Puppet, 0})); + EXPECT_FALSE(harness.game.party().isPuppet(0)); + EXPECT_TRUE(harness.game.party().isRosterAvailable({RosterKind::Puppet, 0})); + EXPECT_FALSE(puppet->isPuppet()); + EXPECT_FALSE(harness.game.getObjectById(puppet->id())); +} + +TEST(RosterBinding, available_unbound_slot_materializes_from_detached_record) { + RosterHarness harness; + Party::PersistedState state; + state.npcAvailable[5] = true; + harness.game.party().setPersistedState(state); + auto record = creatureRecord("lazy_npc"); + replaceSaveField( + *record, Gff::Field::newDword("ObjectId", 136)); + auto bytes = encodeUtc(*record); + EXPECT_CALL( + testEngine().resourceModule().director(), + findSaveWorking(ResourceId("availnpc5", ResType::Utc))) + .WillOnce(Return(Resource {bytes})); + + auto creature = harness.game.party().getAvailableMember(5, true); + + ASSERT_TRUE(creature); + EXPECT_EQ("lazy_npc", creature->tag()); + EXPECT_EQ(creature, harness.game.party().getAvailableMember(5)); + ASSERT_TRUE(creature->saveRecordProvenance()); + ASSERT_TRUE(creature->saveRecordProvenance()->identity); + EXPECT_EQ( + SerializedIdentityDomain::DetachedRecord, + creature->saveRecordProvenance()->identity->context.domain); +} + +TEST(RosterBinding, removing_availability_keeps_lazy_detached_representation_bound) { + RosterHarness harness; + Party::PersistedState state; + state.npcAvailable[5] = true; + harness.game.party().setPersistedState(state); + auto bytes = encodeUtc(*creatureRecord("lazy_npc")); + EXPECT_CALL( + testEngine().resourceModule().director(), + findSaveWorking(ResourceId("availnpc5", ResType::Utc))) + .WillOnce(Return(Resource {bytes})); + auto creature = harness.game.party().getAvailableMember(5, true); + ASSERT_TRUE(creature); + + ASSERT_TRUE(harness.game.party().removeAvailableMember(5)); + + EXPECT_FALSE(harness.game.party().isMemberAvailable(5)); + EXPECT_FALSE(harness.game.party().getAvailableMember(5)); + EXPECT_EQ( + creature, + harness.game.party().rosterCreature({RosterKind::Npc, 5})); + EXPECT_TRUE(harness.game.isRuntimeObjectLive(*creature)); +} + +TEST(RosterBinding, unavailable_stale_record_is_never_materialized) { + RosterHarness harness; + Party::PersistedState state; + harness.game.party().setPersistedState(state); + + EXPECT_FALSE(harness.game.party().getAvailableMember(5, true)); + EXPECT_FALSE(harness.game.party().rosterCreature({RosterKind::Npc, 5})); +} + +TEST(RosterBinding, add_by_object_persists_detached_state_without_binding_input) { + RosterHarness harness; + auto &director = testEngine().resourceModule().director(); + auto committed = std::make_shared(); + EXPECT_CALL(director, committedSaveWorkingState()) + .WillOnce(Return(committed)); + EXPECT_CALL(director, adoptSaveWorkingState(_)) + .WillOnce(Invoke([&committed](auto state) { committed = std::move(state); })); + auto moduleCreature = harness.creature("module_companion"); + moduleCreature->setLocalNumber(7, 111); + + auto result = harness.call( + "AddAvailableNPCByObject", + {Variable::ofInt(6), Variable::ofObject(moduleCreature->id())}); + + EXPECT_EQ(1, result.intValue); + EXPECT_TRUE(harness.game.party().isMemberAvailable(6)); + EXPECT_FALSE(harness.game.party().rosterCreature({RosterKind::Npc, 6})); + EXPECT_FALSE(harness.game.party().rosterIdentity(*moduleCreature)); + EXPECT_TRUE(committed->contains({"availnpc6", ResType::Utc})); +} + +TEST(RosterBinding, rebuild_party_table_is_the_explicit_authored_tag_operation) { + RosterHarness harness; + Party::PersistedState state; + state.npcAvailable[3] = true; + state.puppetAvailable[0] = true; + harness.game.party().setPersistedState(state); + auto area = harness.game.newArea(); + auto gotoCreature = harness.creature("G0T0"); + auto remote = harness.creature("remote"); + auto unrelatedRemote = harness.creature("remote"); + area->add(gotoCreature); + area->add(remote); + area->add(unrelatedRemote); + TestGameModule::configureModuleSnapshot( + harness.game, area, nullptr, "003ebo", "003ebo"); + + harness.call("RebuildPartyTable", {}); + + EXPECT_EQ( + gotoCreature, + harness.game.party().rosterCreature({RosterKind::Npc, 3})); + EXPECT_EQ( + remote, + harness.game.party().rosterCreature({RosterKind::Puppet, 0})); + EXPECT_FALSE(harness.game.party().rosterIdentity(*unrelatedRemote)); +} + +TEST(RosterBinding, add_available_pup_by_object_writes_detached_record_only) { + RosterHarness harness; + auto &director = testEngine().resourceModule().director(); + auto committed = std::make_shared(); + EXPECT_CALL(director, committedSaveWorkingState()) + .WillOnce(Return(committed)); + EXPECT_CALL(director, adoptSaveWorkingState(_)) + .WillOnce(Invoke([&committed](auto state) { committed = std::move(state); })); + auto modulePuppet = harness.creature("remote"); + + auto result = harness.call( + "AddAvailablePUPByObject", + {Variable::ofInt(0), Variable::ofObject(modulePuppet->id())}); + + EXPECT_EQ(1, result.intValue); + EXPECT_TRUE(harness.game.party().isRosterAvailable({RosterKind::Puppet, 0})); + EXPECT_FALSE(harness.game.party().rosterCreature({RosterKind::Puppet, 0})); + EXPECT_FALSE(harness.game.party().rosterIdentity(*modulePuppet)); + EXPECT_TRUE(committed->contains({"availpup0", ResType::Utc})); +} + +TEST(RosterBinding, detached_and_module_graphs_do_not_share_saved_identity) { + RosterHarness harness; + Party::PersistedState state; + state.npcAvailable[0] = true; + harness.game.party().setPersistedState(state); + auto detached = harness.creature("same_character"); + ASSERT_TRUE(harness.game.party().bindRosterCreature( + {RosterKind::Npc, 0}, detached)); + auto moduleObject = harness.creature("same_character"); + const auto context = SerializedIdentityContext::moduleGraph("saved-area"); + auto graph = Gff::Builder() + .field(Gff::Field::newDword("ObjectId", 900)) + .build(); + harness.game.reserveSavedObjectIds( + *graph, context, SerializedGraphRoot::AreaGit); + harness.game.registerSavedObjectIdentity(900, moduleObject, context); + + EXPECT_EQ(moduleObject, harness.game.getObjectBySavedId(900)); + EXPECT_NE(detached, harness.game.getObjectBySavedId(900)); + EXPECT_THROW( + harness.game.registerSavedObjectIdentity(901, moduleObject, context), + ValidationException); +} + +TEST(RosterBinding, k1_rejects_puppet_namespace_and_tenth_npc_slot) { + RosterHarness harness(GameID::KotOR); + + EXPECT_FALSE(harness.game.party().isRosterIdentityValid( + {RosterKind::Puppet, 0})); + EXPECT_FALSE(harness.game.party().isRosterIdentityValid( + {RosterKind::Npc, 9})); + EXPECT_TRUE(harness.game.party().isRosterIdentityValid( + {RosterKind::Npc, 8})); + auto creature = harness.creature("k1_companion"); + auto saved = decodeGff( + SaveWideSnapshotBuilder::availableNpcRecord(harness.game, *creature)); + EXPECT_FALSE(saved->has("AssignedPup")); +} + +TEST(RosterBinding, k2_puppet_assignment_uses_separate_slot_namespace) { + RosterHarness harness; + auto npc = harness.creature("remote"); + auto puppet = harness.creature("remote"); + ASSERT_TRUE(harness.game.party().addAvailableMember(1, npc)); + ASSERT_TRUE(harness.game.party().addAvailablePuppet(0, puppet)); + + ASSERT_TRUE(harness.game.party().addMember(1, npc)); + ASSERT_TRUE(harness.game.party().addPuppet(0, puppet)); + auto assigned = harness.call( + "AssignPUP", {Variable::ofInt(0), Variable::ofInt(1)}); + + EXPECT_EQ(1, assigned.intValue); + EXPECT_TRUE(harness.game.party().isPuppet(0)); + EXPECT_EQ(1, *harness.game.party().assignedNpcForPuppet(0)); + EXPECT_EQ(npc, harness.game.party().puppetOwner(0)); + EXPECT_TRUE(puppet->isPuppet()); + EXPECT_EQ( + npc->id(), + harness.call("GetPUPOwner", {Variable::ofObject(puppet->id())}) + .objectId); + EXPECT_EQ( + 1, + harness.call("GetIsPuppet", {Variable::ofObject(puppet->id())}) + .intValue); + EXPECT_EQ( + (RosterIdentity {RosterKind::Npc, 1}), + *harness.game.party().rosterIdentity(*npc)); + EXPECT_EQ( + (RosterIdentity {RosterKind::Puppet, 0}), + *harness.game.party().rosterIdentity(*puppet)); + auto savedNpc = decodeGff( + SaveWideSnapshotBuilder::availableNpcRecord(harness.game, *npc)); + EXPECT_EQ(0, savedNpc->getInt("AssignedPup", -1)); +} + +TEST(RosterBinding, puppet_assignment_is_creature_state_not_a_unique_slot_map) { + RosterHarness harness; + auto first = harness.creature("first_owner"); + auto second = harness.creature("second_owner"); + auto puppet = harness.creature("remote"); + ASSERT_TRUE(harness.game.party().addAvailableMember(0, first)); + ASSERT_TRUE(harness.game.party().addAvailableMember(1, second)); + ASSERT_TRUE(harness.game.party().addAvailablePuppet(0, puppet)); + ASSERT_TRUE(harness.game.party().addMember(0, first)); + ASSERT_TRUE(harness.game.party().addMember(1, second)); + + ASSERT_TRUE(harness.game.party().assignPuppet(0, 0)); + ASSERT_TRUE(harness.game.party().assignPuppet(0, 1)); + + EXPECT_EQ(0, first->assignedPuppet()); + EXPECT_EQ(0, second->assignedPuppet()); + EXPECT_EQ(first, harness.game.party().puppetOwner(0)); +} + +TEST(RosterBinding, member_lifecycle_activates_and_kills_assigned_puppet) { + RosterHarness harness; + auto &director = testEngine().resourceModule().director(); + auto committed = std::make_shared(); + EXPECT_CALL(director, committedSaveWorkingState()) + .Times(2) + .WillRepeatedly(Invoke([&committed]() { return committed; })); + EXPECT_CALL(director, adoptSaveWorkingState(_)) + .Times(2) + .WillRepeatedly(Invoke( + [&committed](auto state) { committed = std::move(state); })); + auto npc = harness.creature("owner"); + auto puppet = harness.creature("remote"); + npc->setAppearance(271); + puppet->setAppearance(314); + ASSERT_TRUE(harness.game.party().addAvailableMember(0, npc)); + ASSERT_TRUE(harness.game.party().addAvailablePuppet(0, puppet)); + ASSERT_TRUE(harness.game.party().assignPuppet(0, 0)); + + ASSERT_TRUE(harness.game.party().addMember(0, npc)); + EXPECT_TRUE(harness.game.party().isPuppet(0)); + EXPECT_EQ(puppet, harness.game.party().getAvailablePuppet(0)); + + ASSERT_TRUE(harness.game.party().removeMember(0)); + EXPECT_FALSE(harness.game.party().isPuppet(0)); + EXPECT_FALSE(harness.game.party().getAvailablePuppet(0)); + EXPECT_TRUE(harness.game.party().isRosterAvailable({RosterKind::Puppet, 0})); + EXPECT_FALSE(harness.game.getObjectById(puppet->id())); + auto savedNpc = committed->find({"availnpc0", ResType::Utc}); + auto savedPuppet = committed->find({"availpup0", ResType::Utc}); + ASSERT_TRUE(savedNpc); + ASSERT_TRUE(savedPuppet); + EXPECT_EQ(271u, decodeGff(savedNpc->data)->getUint("Appearance_Type")); + EXPECT_EQ(314u, decodeGff(savedPuppet->data)->getUint("Appearance_Type")); +} + +TEST(RemoveNPCFromPartyToBase, persists_then_retires_the_exact_runtime_companion) { + RosterHarness harness; + auto player = harness.creature("player"); + auto companion = harness.creature("companion"); + auto area = harness.game.newArea(); + TestGameModule::configureModuleSnapshot( + harness.game, area, player, "source", "source"); + area->add(player); + + companion->setCurrentHitPoints(17); + companion->setLocalNumber(7, 137); + companion->addAction(harness.game.newAction(30.0f)); + companion->applyEffect( + harness.game.newEffect(EffectType::Haste), + DurationType::Temporary, + 30.0f); + auto carried = harness.game.newItem( + *itemRecord("carried"), + SerializedIdentityContext::templateResource()); + auto equipped = harness.game.newItem( + *itemRecord("equipped"), + SerializedIdentityContext::templateResource()); + companion->addItem(carried); + ASSERT_TRUE(companion->equip(InventorySlots::body, equipped)); + ASSERT_TRUE(harness.game.party().addAvailableMember(4, companion)); + ASSERT_TRUE(harness.game.party().addMember(4, companion)); + area->add(companion); + + auto committed = std::make_shared(); + EXPECT_CALL( + testEngine().resourceModule().director(), committedSaveWorkingState()) + .WillOnce(Invoke([&committed]() { return committed; })); + EXPECT_CALL( + testEngine().resourceModule().director(), adoptSaveWorkingState(_)) + .WillOnce(Invoke( + [&committed](auto state) { committed = std::move(state); })); + + const uint32_t oldId = companion->id(); + Variable result = harness.call( + "RemoveNPCFromPartyToBase", {Variable::ofInt(4)}); + + EXPECT_EQ(1, result.intValue); + EXPECT_TRUE(harness.game.party().isMemberAvailable(4)); + EXPECT_FALSE(harness.game.party().isMember(4)); + EXPECT_FALSE(harness.game.party().getAvailableMember(4)); + EXPECT_FALSE(harness.game.getObjectById(oldId)); + EXPECT_FALSE(companion->isRuntimeLive()); + EXPECT_EQ(nullptr, companion->room()); + EXPECT_EQ( + area->objects().end(), + std::find(area->objects().begin(), area->objects().end(), companion)); + + auto savedResource = committed->find({"availnpc4", ResType::Utc}); + ASSERT_TRUE(savedResource); + auto saved = decodeGff(savedResource->data); + EXPECT_EQ(17, saved->getInt("CurrentHitPoints")); + EXPECT_EQ(1u, saved->getList("ActionList").size()); + EXPECT_TRUE(saved->getList("EffectList").empty()); + EXPECT_EQ(1u, saved->getList("ItemList").size()); + EXPECT_EQ(1u, saved->getList("Equip_ItemList").size()); + EXPECT_FALSE(carried->isRuntimeLive()); + EXPECT_FALSE(equipped->isRuntimeLive()); +} + +TEST(RemoveNPCFromPartyToBase, readding_materializes_one_fresh_representation) { + RosterHarness harness; + auto player = harness.creature("player"); + auto companion = harness.creature("companion"); + auto area = harness.game.newArea(); + TestGameModule::configureModuleSnapshot( + harness.game, area, player, "source", "source"); + area->add(player); + companion->setLocalNumber(7, 137); + ASSERT_TRUE(harness.game.party().addAvailableMember(4, companion)); + ASSERT_TRUE(harness.game.party().addMember(4, companion)); + area->add(companion); + + auto committed = std::make_shared(); + EXPECT_CALL( + testEngine().resourceModule().director(), committedSaveWorkingState()) + .WillOnce(Invoke([&committed]() { return committed; })); + EXPECT_CALL( + testEngine().resourceModule().director(), adoptSaveWorkingState(_)) + .WillOnce(Invoke( + [&committed](auto state) { committed = std::move(state); })); + ASSERT_EQ( + 1, + harness.call( + "RemoveNPCFromPartyToBase", {Variable::ofInt(4)}) + .intValue); + + EXPECT_CALL( + testEngine().resourceModule().director(), + findSaveWorking(ResourceId("availnpc4", ResType::Utc))) + .WillOnce(Invoke([&committed]() { + auto saved = committed->find({"availnpc4", ResType::Utc}); + return Resource {saved->data}; + })); + auto replacement = harness.game.party().getAvailableMember(4, true); + ASSERT_TRUE(replacement); + ASSERT_NE(companion, replacement); + EXPECT_EQ(137, replacement->getLocalNumber(7)); + EXPECT_TRUE(harness.game.party().addMember(4, replacement)); + area->add(replacement); + + EXPECT_EQ(replacement, harness.game.party().getMemberByNPC(4)); + EXPECT_EQ(replacement, harness.game.party().getAvailableMember(4)); + EXPECT_EQ(1u, std::count_if( + harness.game.party().members().begin(), + harness.game.party().members().end(), + [](const Party::Member &member) { + return member.npc == 4; + })); +} + +TEST(RemoveNPCFromPartyToBase, detached_vitality_preserves_damage_on_rematerialization) { + RosterHarness harness; + NiceMock strings; + NiceMock twoDas; + Classes classes(strings, twoDas); + auto soldier = std::make_shared( + ClassType::Soldier, classes, strings, twoDas); + EXPECT_CALL(testEngine().gameModule().classes(), get(ClassType::Soldier)) + .WillRepeatedly(Return(soldier)); + + auto player = harness.creature("player"); + auto companion = harness.creature("companion"); + auto area = harness.game.newArea(); + TestGameModule::configureModuleSnapshot( + harness.game, area, player, "source", "source"); + area->add(player); + companion->attributes().addClassLevels(soldier.get(), 3); + companion->attributes().setAbilityScore(Ability::Constitution, 12); + companion->attributes().addFeat(FeatType::Toughness); + companion->setMaxHitPoints(30); + companion->setCurrentHitPoints(32); + ASSERT_TRUE(harness.game.party().addAvailableMember(4, companion)); + ASSERT_TRUE(harness.game.party().addMember(4, companion)); + EXPECT_EQ(36, companion->maxHitPoints()); + EXPECT_EQ(32, companion->currentHitPoints()); + area->add(companion); + + auto committed = std::make_shared(); + EXPECT_CALL( + testEngine().resourceModule().director(), committedSaveWorkingState()) + .WillOnce(Invoke([&committed]() { return committed; })); + EXPECT_CALL( + testEngine().resourceModule().director(), adoptSaveWorkingState(_)) + .WillOnce(Invoke( + [&committed](auto state) { committed = std::move(state); })); + ASSERT_EQ( + 1, + harness.call( + "RemoveNPCFromPartyToBase", {Variable::ofInt(4)}) + .intValue); + + auto savedResource = committed->find({"availnpc4", ResType::Utc}); + ASSERT_TRUE(savedResource); + auto saved = decodeGff(savedResource->data); + EXPECT_EQ(30, saved->getInt("HitPoints")); + EXPECT_EQ(36, saved->getInt("MaxHitPoints")); + EXPECT_EQ(26, saved->getInt("CurrentHitPoints")); + + EXPECT_CALL( + testEngine().resourceModule().director(), + findSaveWorking(ResourceId("availnpc4", ResType::Utc))) + .WillOnce(Invoke([&committed]() { + auto record = committed->find({"availnpc4", ResType::Utc}); + return Resource {record->data}; + })); + auto replacement = harness.game.party().getAvailableMember(4, true); + ASSERT_TRUE(replacement); + EXPECT_EQ(30, replacement->hitPoints()); + EXPECT_EQ(36, replacement->maxHitPoints()); + EXPECT_EQ(32, replacement->currentHitPoints()); + EXPECT_EQ(26, replacement->serializedCurrentHitPoints()); +} + +TEST(RemoveNPCFromPartyToBase, retires_the_active_assigned_puppet_but_keeps_assignment) { + RosterHarness harness; + auto player = harness.creature("player"); + auto companion = harness.creature("bao_dur"); + auto puppet = harness.creature("remote"); + auto area = harness.game.newArea(); + TestGameModule::configureModuleSnapshot( + harness.game, area, player, "source", "source"); + area->add(player); + ASSERT_TRUE(harness.game.party().addAvailableMember(1, companion)); + ASSERT_TRUE(harness.game.party().addAvailablePuppet(0, puppet)); + ASSERT_TRUE(harness.game.party().assignPuppet(0, 1)); + ASSERT_TRUE(harness.game.party().addMember(1, companion)); + ASSERT_TRUE(harness.game.party().isPuppet(0)); + area->add(companion); + area->add(puppet); + + auto committed = std::make_shared(); + EXPECT_CALL( + testEngine().resourceModule().director(), committedSaveWorkingState()) + .Times(2) + .WillRepeatedly(Invoke([&committed]() { return committed; })); + EXPECT_CALL( + testEngine().resourceModule().director(), adoptSaveWorkingState(_)) + .Times(2) + .WillRepeatedly(Invoke( + [&committed](auto state) { committed = std::move(state); })); + + EXPECT_EQ( + 1, + harness.call( + "RemoveNPCFromPartyToBase", {Variable::ofInt(1)}) + .intValue); + + EXPECT_TRUE(harness.game.party().isMemberAvailable(1)); + EXPECT_TRUE(harness.game.party().isRosterAvailable( + {RosterKind::Puppet, 0})); + EXPECT_FALSE(harness.game.party().isPuppet(0)); + EXPECT_FALSE(harness.game.party().getAvailablePuppet(0)); + EXPECT_FALSE(harness.game.party().getAvailableMember(1)); + EXPECT_FALSE(puppet->isRuntimeLive()); + auto savedNpcResource = committed->find({"availnpc1", ResType::Utc}); + ASSERT_TRUE(savedNpcResource); + EXPECT_EQ(0, decodeGff(savedNpcResource->data)->getInt("AssignedPup", -1)); + EXPECT_TRUE(committed->find({"availpup0", ResType::Utc}).has_value()); +} + +TEST(RemoveNPCFromPartyToBase, controlled_companion_returns_control_to_the_canonical_pc) { + RosterHarness harness; + auto canonical = harness.creature("player"); + auto controlled = harness.creature("controlled"); + auto area = harness.game.newArea(); + TestGameModule::configureModuleSnapshot( + harness.game, area, canonical, "source", "source"); + area->add(canonical); + ASSERT_TRUE(harness.game.party().addAvailableMember(4, controlled)); + harness.game.party().setControlledMember(4, controlled); + harness.game.party().setSoloMode(true); + area->add(controlled); + ASSERT_EQ(controlled, harness.game.party().player()); + + auto committed = std::make_shared(); + EXPECT_CALL( + testEngine().resourceModule().director(), committedSaveWorkingState()) + .WillOnce(Invoke([&committed]() { return committed; })); + EXPECT_CALL( + testEngine().resourceModule().director(), adoptSaveWorkingState(_)) + .WillOnce(Invoke( + [&committed](auto state) { committed = std::move(state); })); + + EXPECT_EQ( + 1, + harness.call( + "RemoveNPCFromPartyToBase", {Variable::ofInt(4)}) + .intValue); + + EXPECT_EQ(kNpcPlayer, harness.game.party().controlledNpc()); + EXPECT_EQ(canonical, harness.game.party().player()); + EXPECT_EQ(canonical, harness.game.party().getLeader()); + EXPECT_TRUE(harness.game.party().isMember(*canonical)); + EXPECT_FALSE(harness.game.party().isSoloMode()); + EXPECT_FALSE(harness.game.party().isMember(4)); + EXPECT_FALSE(harness.game.party().getAvailableMember(4)); + EXPECT_TRUE(canonical->isRuntimeLive()); +} + +TEST(RemoveNPCFromPartyToBase, repeated_board_and_selection_cycles_do_not_accumulate_representations) { + RosterHarness harness; + auto player = harness.creature("player"); + auto first = harness.creature("first"); + auto second = harness.creature("second"); + auto area = harness.game.newArea(); + TestGameModule::configureModuleSnapshot( + harness.game, area, player, "hawk_cycle", "hawk_cycle"); + area->add(player); + ASSERT_TRUE(harness.game.party().addAvailableMember(0, first)); + ASSERT_TRUE(harness.game.party().addAvailableMember(1, second)); + ASSERT_TRUE(harness.game.party().addMember(0, first)); + ASSERT_TRUE(harness.game.party().addMember(1, second)); + area->add(first); + area->add(second); + const size_t stableRegistrySize = + TestGameModule::objectRegistrySize(harness.game); + + auto committed = std::make_shared(); + EXPECT_CALL( + testEngine().resourceModule().director(), committedSaveWorkingState()) + .Times(6) + .WillRepeatedly(Invoke([&committed]() { return committed; })); + EXPECT_CALL( + testEngine().resourceModule().director(), adoptSaveWorkingState(_)) + .Times(6) + .WillRepeatedly(Invoke( + [&committed](auto state) { committed = std::move(state); })); + EXPECT_CALL( + testEngine().resourceModule().director(), findSaveWorking(_)) + .Times(6) + .WillRepeatedly(Invoke([&committed](const ResourceId &id) { + auto saved = committed->find(id); + return Resource {saved->data}; + })); + + for (int cycle = 0; cycle < 3; ++cycle) { + ASSERT_EQ( + 1, + harness.call( + "RemoveNPCFromPartyToBase", {Variable::ofInt(0)}) + .intValue); + ASSERT_EQ( + 1, + harness.call( + "RemoveNPCFromPartyToBase", {Variable::ofInt(1)}) + .intValue); + EXPECT_EQ(1, harness.game.party().getSize()); + EXPECT_FALSE(harness.game.party().rosterCreature( + {RosterKind::Npc, 0})); + EXPECT_FALSE(harness.game.party().rosterCreature( + {RosterKind::Npc, 1})); + + auto materializedFirst = + harness.game.party().getAvailableMember(0, true); + auto materializedSecond = + harness.game.party().getAvailableMember(1, true); + ASSERT_TRUE(materializedFirst); + ASSERT_TRUE(materializedSecond); + area->add(materializedFirst); + area->add(materializedSecond); + ASSERT_TRUE(harness.game.reconcilePartySelection({0, 1})); + + EXPECT_TRUE(harness.game.isPartySelectionRealized({0, 1})); + EXPECT_EQ(3, harness.game.party().getSize()); + EXPECT_EQ( + stableRegistrySize, + TestGameModule::objectRegistrySize(harness.game)); + EXPECT_EQ(1u, std::count_if( + area->objects().begin(), area->objects().end(), + [&materializedFirst](const auto &object) { + return object == materializedFirst; + })); + EXPECT_EQ(1u, std::count_if( + area->objects().begin(), area->objects().end(), + [&materializedSecond](const auto &object) { + return object == materializedSecond; + })); + } +} + +TEST(RemoveNPCFromPartyToBase, rejects_invalid_and_unbound_slots) { + RosterHarness harness; + Party::PersistedState state; + state.npcAvailable[4] = true; + harness.game.party().setPersistedState(state); + + EXPECT_EQ( + 0, + harness.call( + "RemoveNPCFromPartyToBase", {Variable::ofInt(-1)}) + .intValue); + EXPECT_EQ( + 0, + harness.call( + "RemoveNPCFromPartyToBase", + {Variable::ofInt(static_cast(Party::kK2NpcCount))}) + .intValue); + EXPECT_EQ( + 0, + harness.call( + "RemoveNPCFromPartyToBase", {Variable::ofInt(3)}) + .intValue); + EXPECT_EQ( + 0, + harness.call( + "RemoveNPCFromPartyToBase", {Variable::ofInt(4)}) + .intValue); +} + +TEST(RemoveNPCFromPartyToBase, retires_an_unavailable_but_still_bound_representation) { + RosterHarness harness; + auto companion = harness.creature("unavailable_bound"); + ASSERT_TRUE(harness.game.party().addAvailableMember(4, companion)); + ASSERT_TRUE(harness.game.party().removeAvailableMember(4)); + ASSERT_FALSE(harness.game.party().isMemberAvailable(4)); + ASSERT_EQ(companion, harness.game.party().rosterCreature( + {RosterKind::Npc, 4})); + + EXPECT_EQ( + 1, + harness.call( + "RemoveNPCFromPartyToBase", {Variable::ofInt(4)}) + .intValue); + + EXPECT_FALSE(harness.game.party().isMemberAvailable(4)); + EXPECT_FALSE(harness.game.party().rosterCreature( + {RosterKind::Npc, 4})); + EXPECT_FALSE(companion->isRuntimeLive()); +} + +TEST(RemoveNPCFromPartyToBase, routine_is_tsl_only) { + RosterHarness tsl(GameID::TSL); + RosterHarness kotor(GameID::KotOR); + + EXPECT_EQ( + 846, + tsl.routines.getIndexByName("RemoveNPCFromPartyToBase")); + EXPECT_EQ( + -1, + kotor.routines.getIndexByName("RemoveNPCFromPartyToBase")); +} diff --git a/test/game/runtimesession.cpp b/test/game/runtimesession.cpp index 4988c5bc9..52ce8508c 100644 --- a/test/game/runtimesession.cpp +++ b/test/game/runtimesession.cpp @@ -15,15 +15,35 @@ #include "../fixtures/engine.h" #include "reone/game/action.h" +#include "reone/game/action/castfakespellatobject.h" +#include "reone/game/action/equipitem.h" #include "reone/game/effect.h" +#include "reone/game/effect/assuredhit.h" +#include "reone/game/effect/beam.h" +#include "reone/game/effect/modifyattacks.h" +#include "reone/game/event.h" +#include "reone/game/equipmentrules.h" #include "reone/game/game.h" #include "reone/game/gui/conversation.h" #include "reone/game/gui/hud.h" #include "reone/game/object/area.h" #include "reone/game/object/creature.h" +#include "reone/game/object/item.h" #include "reone/game/object/module.h" +#include "reone/game/object/placeable.h" +#include "reone/game/object/store.h" +#include "reone/game/object/trigger.h" #include "reone/game/room.h" +#include "reone/game/script/routines.h" +#include "reone/graphics/model.h" +#include "reone/graphics/modelnode.h" +#include "reone/script/executioncontext.h" +#include "reone/script/variable.h" #include "reone/scene/collision.h" +#include "reone/scene/node/model.h" +#include "reone/resource/2da.h" +#include "reone/resource/gff.h" +#include "reone/system/exception/validation.h" using namespace reone; using namespace reone::game; @@ -47,6 +67,34 @@ class CountingAction : public reone::game::Action { int &_executions; }; +class TargetCountingAction : public reone::game::Action { +public: + TargetCountingAction( + Game &game, + ServicesView &services, + std::shared_ptr target, + int &executions) : + reone::game::Action(game, services, ActionType::Invalid), + _target(std::move(target)), + _executions(executions) { + requireRuntimeObject(_target); + } + + void execute( + std::shared_ptr, + Object &, + float) override { + ++_executions; + complete(); + } + +private: + // Deliberately retain storage to prove strong ownership cannot confer + // gameplay liveness after semantic destruction. + std::shared_ptr _target; + int &_executions; +}; + class SessionConversation : public Conversation { public: SessionConversation(Game &game, ServicesView &services) : @@ -54,9 +102,9 @@ class SessionConversation : public Conversation { } void bindOwner(std::shared_ptr owner) { - _owner = std::move(owner); + _owner = owner; _dialog = std::make_shared(); - _owner->setIsInConversation(true); + owner->setIsInConversation(true); } protected: @@ -75,6 +123,64 @@ void configureRuntimeMocks( .WillByDefault(ReturnRef(sceneGraph)); } +std::shared_ptr runtimeBaseItems(int maxStack = 99) { + resource::TwoDA::Builder builder; + builder.columns( + {"equipableslots", "itemclass", "ammunitiontype", "maxstack"}); + builder.row({"2", "i_test", "0", std::to_string(maxStack)}); + return std::shared_ptr(builder.build()); +} + +std::shared_ptr runtimePlaceables() { + resource::TwoDA::Builder builder; + builder.columns({"modelname"}); + builder.row({""}); + return std::shared_ptr(builder.build()); +} + +std::shared_ptr runtimeAppearances() { + resource::TwoDA::Builder builder; + builder.columns( + {"modeltype", "walkdist", "rundist", "perspace", "creperspace", + "sizecategory", "footsteptype", "envmap", "race"}); + builder.row({"S", "1", "1", "0.6", "0.6", "3", "-1", "", ""}); + return std::shared_ptr(builder.build()); +} + +std::shared_ptr runtimeItem( + std::string tag, + std::optional savedId = std::nullopt, + uint32_t structType = 0, + uint16_t stackSize = 1, + uint8_t charges = 0) { + resource::Gff::Builder builder; + builder.type(structType) + .field(resource::Gff::Field::newCExoString("Tag", std::move(tag))) + .field(resource::Gff::Field::newInt("BaseItem", 0)) + .field(resource::Gff::Field::newWord("StackSize", stackSize)) + .field(resource::Gff::Field::newByte("Charges", charges)); + if (savedId) { + builder.field(resource::Gff::Field::newDword("ObjectId", *savedId)); + } + return builder.build(); +} + +void giveItemThroughRoutine( + TestEngine &engine, + Game &game, + const std::shared_ptr &item, + const std::shared_ptr &receiver) { + Routines routines(game.gameId(), &game, &engine.services()); + routines.init(); + script::ExecutionContext execution; + execution.routines = &routines; + routines.get(routines.getIndexByName("GiveItem")) + .invoke( + {script::Variable::ofObject(item->id()), + script::Variable::ofObject(receiver->id())}, + execution); +} + } // namespace void reone::game::TestGameModule::cacheActiveModule(Game &game, std::string name) { @@ -93,10 +199,50 @@ uint32_t reone::game::TestGameModule::nextObjectId(const Game &game) { return game._nextObjectId; } +std::shared_ptr reone::game::TestGameModule::newItemAtRuntimeId( + Game &game, uint32_t id) { + return game.newObjectAtId(id, false, game, game._services); +} + uint64_t reone::game::TestGameModule::runtimeSessionGeneration(const Game &game) { return game._runtimeSessionGeneration; } +uint64_t reone::game::TestGameModule::savedGraphGeneration(const Game &game) { + return game._savedGraphGeneration; +} + +void reone::game::TestGameModule::setAreaRuntimePath( + Creature &creature, Pathfinder &pathfinder) { + pathfinder.paths.emplace_back(); + pathfinder.paths.back().active = true; + creature._path = Path {static_cast(pathfinder.paths.size() - 1)}; +} + +bool reone::game::TestGameModule::hasAreaRuntimePath(const Creature &creature) { + return creature._path.has_value(); +} + +size_t reone::game::TestGameModule::seenObjectCount(const Creature &creature) { + return creature._perception.seen.size(); +} + +size_t reone::game::TestGameModule::heardObjectCount(const Creature &creature) { + return creature._perception.heard.size(); +} + +void reone::game::TestGameModule::setAreaRuntimeSceneNode( + Object &object, std::shared_ptr sceneNode) { + object._sceneNode = std::move(sceneNode); +} + +void reone::game::TestGameModule::registerSavedModuleReferenceTarget( + Game &game, + const std::shared_ptr &module, + const SerializedIdentityContext &identityContext) { + game.registerSavedModuleReferenceTarget(module, identityContext); +} + void reone::game::TestGameModule::markSpawnScriptFired(Creature &creature) { creature._spawnScriptFired = true; } @@ -388,6 +534,7 @@ TEST(RuntimeSession, saved_session_can_publish_repeated_ordinary_module_transiti game.openInGame(); auto sessionGeneration = TestGameModule::runtimeSessionGeneration(game); + auto graphGeneration = TestGameModule::savedGraphGeneration(game); auto oldModuleId = oldModule->id(); auto oldAreaId = oldArea->id(); auto oldWorldObjectId = oldWorldObject->id(); @@ -408,6 +555,9 @@ TEST(RuntimeSession, saved_session_can_publish_repeated_ordinary_module_transiti EXPECT_EQ(available, game.getObjectById(available->id())); EXPECT_EQ(9, game.getGlobalNumber("SESSION")); EXPECT_EQ(sessionGeneration, TestGameModule::runtimeSessionGeneration(game)); + EXPECT_EQ( + graphGeneration + 1, + TestGameModule::savedGraphGeneration(game)); TestGameModule::setActiveModule(game, true); TestGameModule::cacheActiveModule(game, "module_b"); @@ -436,24 +586,36 @@ TEST(RuntimeSession, saved_session_can_publish_repeated_ordinary_module_transiti EXPECT_FALSE(game.getObjectById(targetArea->id())); EXPECT_FALSE(game.getObjectById(targetWorldObject->id())); EXPECT_EQ(sessionGeneration, TestGameModule::runtimeSessionGeneration(game)); + EXPECT_EQ( + graphGeneration + 2, + TestGameModule::savedGraphGeneration(game)); } TEST(ModuleLoadContext, separates_saved_world_provenance_from_session_placement) { auto fresh = resolveModuleLoadContext(false, false); + auto initialTemplate = resolveModuleLoadContext(true, false); auto initialRestore = resolveModuleLoadContext(true, true); auto savedTransition = resolveModuleLoadContext(false, true); EXPECT_EQ(ModuleLoadContext::FreshModule, fresh); EXPECT_FALSE(restoresSavedWorld(fresh)); EXPECT_FALSE(restoresSavedSession(fresh)); + EXPECT_FALSE(preservesSavedPlacement(fresh)); + + EXPECT_EQ(ModuleLoadContext::InitialTemplateRestore, initialTemplate); + EXPECT_FALSE(restoresSavedWorld(initialTemplate)); + EXPECT_TRUE(restoresSavedSession(initialTemplate)); + EXPECT_FALSE(preservesSavedPlacement(initialTemplate)); EXPECT_EQ(ModuleLoadContext::InitialSaveRestore, initialRestore); EXPECT_TRUE(restoresSavedWorld(initialRestore)); EXPECT_TRUE(restoresSavedSession(initialRestore)); + EXPECT_TRUE(preservesSavedPlacement(initialRestore)); EXPECT_EQ(ModuleLoadContext::SavedModuleTransition, savedTransition); EXPECT_TRUE(restoresSavedWorld(savedTransition)); EXPECT_FALSE(restoresSavedSession(savedTransition)); + EXPECT_FALSE(preservesSavedPlacement(savedTransition)); } TEST(RuntimeSession, ordinary_transition_repositions_live_party_and_rebinds_its_room) { @@ -489,6 +651,7 @@ TEST(RuntimeSession, ordinary_transition_repositions_live_party_and_rebinds_its_ game.party().setActualPlayer(player); auto companion = game.newCreature(); companion->setPosition(glm::vec3(90.0f, 145.0f, 3.75f)); + game.party().addAvailableMember(0, companion); game.party().addMember(0, companion); sourceArea->add(player); @@ -502,11 +665,11 @@ TEST(RuntimeSession, ordinary_transition_repositions_live_party_and_rebinds_its_ ASSERT_TRUE(sourceRoom.tenants().count(player.get())); ASSERT_TRUE(sourceRoom.tenants().count(companion.get())); - sourceArea->unloadParty(); + sourceArea->retirePartyAreaRuntime(); EXPECT_EQ(nullptr, player->room()); EXPECT_EQ(nullptr, companion->room()); - EXPECT_TRUE(player->isStuntMode()); + EXPECT_FALSE(player->isStuntMode()); EXPECT_FALSE(sourceRoom.tenants().count(player.get())); EXPECT_FALSE(sourceRoom.tenants().count(companion.get())); EXPECT_EQ(player, game.party().player()); @@ -515,8 +678,8 @@ TEST(RuntimeSession, ordinary_transition_repositions_live_party_and_rebinds_its_ EXPECT_EQ(17, player->currentHitPoints()); ASSERT_EQ(1, player->effects().size()); EXPECT_EQ(effect, player->effects().front().effect); - ASSERT_EQ(1, player->actions().size()); - EXPECT_EQ(action, player->actions().front()); + EXPECT_TRUE(player->actions().empty()); + EXPECT_TRUE(action->isCancelled()); EXPECT_EQ(0, actionExecutions); auto destinationArea = game.newArea(); @@ -534,7 +697,7 @@ TEST(RuntimeSession, ordinary_transition_repositions_live_party_and_rebinds_its_ EXPECT_EQ(player, game.getObjectById(player->id())); EXPECT_EQ(17, player->currentHitPoints()); EXPECT_EQ(1, player->effects().size()); - EXPECT_EQ(1, player->actions().size()); + EXPECT_TRUE(player->actions().empty()); EXPECT_EQ(0, actionExecutions); auto &destinationCreatures = destinationArea->getObjectsByType(ObjectType::Creature); EXPECT_NE(destinationCreatures.end(), @@ -641,6 +804,7 @@ struct PartyTransferHarness { companion = game->newCreature(); companion->setOnSpawn("npc_spawn"); + game->party().addAvailableMember(0, companion); game->party().addMember(0, companion); } @@ -648,7 +812,7 @@ struct PartyTransferHarness { // area is mounted, and the same party creatures are positioned into it. std::shared_ptr transitionTo(const glm::vec3 &entry, float facing) { if (area) { - area->unloadParty(); + area->retirePartyAreaRuntime(); } auto previous = std::move(area); area = game->newArea(); @@ -746,7 +910,42 @@ TEST(PartyTransferSpawn, repeated_transitions_never_respawn_the_same_party) { } } -TEST(PartyTransferSpawn, transferred_party_member_keeps_its_runtime_state) { +TEST(PartyTransferSpawn, k2_active_puppet_remains_single_across_transitions) { + PartyTransferHarness harness(resource::GameID::TSL); + auto puppet = harness.game->newCreature(); + puppet->setTag("remote"); + ASSERT_TRUE(harness.game->party().addAvailablePuppet(0, puppet)); + ASSERT_TRUE(harness.game->party().addPuppet(0, puppet)); + const auto puppetId = puppet->id(); + auto *puppetObject = puppet.get(); + + for (int cycle = 0; cycle < 10; ++cycle) { + SCOPED_TRACE(cycle); + harness.transitionTo( + glm::vec3(static_cast(cycle), 2.0f, 0.0f), + static_cast(cycle) * 0.1f); + + EXPECT_TRUE(harness.game->party().isPuppet(0)); + EXPECT_EQ( + puppet, + harness.game->party().rosterCreature({RosterKind::Puppet, 0})); + EXPECT_EQ(puppetObject, puppet.get()); + EXPECT_EQ(puppetId, puppet->id()); + EXPECT_EQ(puppet, harness.game->getObjectById(puppetId)); + + const auto &creatures = + harness.area->getObjectsByType(ObjectType::Creature); + EXPECT_EQ(1, std::count(creatures.begin(), creatures.end(), puppet)); + EXPECT_EQ( + 1, + std::count_if( + creatures.begin(), creatures.end(), [](const auto &creature) { + return creature && creature->tag() == "remote"; + })); + } +} + +TEST(PartyTransferSpawn, transferred_party_member_keeps_durable_not_area_execution_state) { for (auto gameId : {resource::GameID::KotOR, resource::GameID::TSL}) { PartyTransferHarness harness(gameId); harness.transitionTo(glm::vec3(0.0f, 0.0f, 0.0f), 0.0f); @@ -772,8 +971,8 @@ TEST(PartyTransferSpawn, transferred_party_member_keeps_its_runtime_state) { EXPECT_EQ(42, harness.companion->getLocalNumber(5)); ASSERT_EQ(1, harness.companion->effects().size()); EXPECT_EQ(effect, harness.companion->effects().front().effect); - ASSERT_EQ(1, harness.companion->actions().size()); - EXPECT_EQ(action, harness.companion->actions().front()); + EXPECT_TRUE(harness.companion->actions().empty()); + EXPECT_TRUE(action->isCancelled()); EXPECT_EQ(0, executions); ASSERT_EQ(1, harness.companion->items().size()); EXPECT_EQ(carried, harness.companion->items().front()); @@ -783,6 +982,1682 @@ TEST(PartyTransferSpawn, transferred_party_member_keeps_its_runtime_state) { } } +TEST(AreaRuntimeRetirement, canonical_boundary_retires_every_area_owned_attachment) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + StubConsole console; + Game game(resource::GameID::TSL, "", engine.options(), engine.services(), console); + + auto area = game.newArea(); + auto retained = game.newCreature(); + auto outgoing = game.newCreature(); + auto trigger = game.newTrigger(); + trigger->deserialize( + *resource::Gff::Builder().build(), + SerializedIdentityContext::templateResource()); + + Party::PersistedState state; + state.controlledNpc = 0; + state.npcAvailable[0] = true; + game.party().setPersistedState(state); + game.party().addAvailableMember(0, retained); + game.party().addMember(0, retained); + game.party().setPlayer(retained); + game.party().setActualPlayer(game.newCreature()); + + auto root = std::make_shared( + 0, "retained", glm::vec3(0.0f), + glm::quat(1.0f, 0.0f, 0.0f, 0.0f), true, nullptr); + auto model = std::make_unique( + "retained", 0, root, + std::vector>(), "", 1.0f); + auto sceneNode = std::make_shared( + *model, scene::ModelUsage::Creature, sceneGraph, + engine.graphicsModule().services(), + engine.audioModule().services(), + engine.resourceModule().services()); + TestGameModule::setAreaRuntimeSceneNode(*retained, sceneNode); + + area->add(outgoing); + area->add(trigger); + area->add(retained); + Room room("source", glm::vec3(0.0f), nullptr, nullptr, nullptr); + retained->setRoom(&room); + trigger->addTenant(retained); + TestGameModule::setAreaRuntimePath(*retained, area->pathfinder()); + + retained->beginCombatAttack(outgoing, FeatType::Invalid); + retained->setAttemptedAttackTarget(outgoing->id()); + retained->setLastHostileActor(outgoing->id()); + retained->setObjectSeen(outgoing, true); + retained->setObjectHeard(outgoing, true); + retained->setBlockingDoor(outgoing->id()); + retained->startStuntMode(); + + int executions = 0; + auto action = game.newAction(executions); + auto delayed = game.newAction(executions); + retained->addAction(action); + retained->delayAction(delayed, 30.0f); + + retained->setCurrentHitPoints(19); + retained->setLocalBoolean(3, true); + retained->setLocalNumber(4, 23); + retained->setAppearance(42); + auto carried = game.newOwnedItem(); + retained->addItem(carried); + auto durableEffect = std::make_shared(EffectType::Invalid); + retained->applyEffect(durableEffect, DurationType::Permanent); + retained->applyEffect( + std::make_shared(1), DurationType::Permanent); + retained->applyEffect( + std::make_shared(), DurationType::Permanent); + ASSERT_EQ(1, retained->modifiedAttacks()); + ASSERT_TRUE(retained->hasAssuredHit()); + + std::weak_ptr outgoingEffectTarget = outgoing; + auto beamEffect = std::make_shared( + 0, outgoing, BodyNode::Chest, false); + retained->applyEffect(beamEffect, DurationType::Permanent); + + EffectInstance areaBoundEffect; + areaBoundEffect.effect = std::make_shared(EffectType::Invalid); + areaBoundEffect.id = game.allocateEffectId(); + areaBoundEffect.subType = static_cast(DurationType::Permanent); + areaBoundEffect.creatorId = outgoing->id(); + areaBoundEffect.objectParameters[0] = outgoing->id(); + ASSERT_TRUE(game.bindEffectCreator(areaBoundEffect)); + ASSERT_TRUE(retained->restoreEffect(std::move(areaBoundEffect))); + + ASSERT_EQ(&room, retained->room()); + ASSERT_TRUE(room.tenants().count(retained.get())); + ASSERT_TRUE(trigger->isTenant(retained)); + ASSERT_TRUE(TestGameModule::hasAreaRuntimePath(*retained)); + ASSERT_TRUE(retained->getAttackTarget()); + ASSERT_EQ(1u, TestGameModule::seenObjectCount(*retained)); + ASSERT_EQ(1u, TestGameModule::heardObjectCount(*retained)); + ASSERT_EQ(1u, TestGameModule::delayedActionCount(*retained)); + EXPECT_CALL(sceneGraph, removeRoot(A())) + .WillOnce(Invoke([expected = sceneNode.get()](scene::ModelSceneNode &node) { + EXPECT_EQ(expected, &node); + })); + + area->retireCreatureAreaRuntime(retained); + + EXPECT_EQ(nullptr, retained->room()); + EXPECT_FALSE(room.tenants().count(retained.get())); + EXPECT_FALSE(trigger->isTenant(retained)); + EXPECT_FALSE(TestGameModule::hasAreaRuntimePath(*retained)); + ASSERT_EQ(1u, area->pathfinder().paths.size()); + EXPECT_FALSE(area->pathfinder().paths.front().active); + EXPECT_FALSE(retained->isInCombat()); + EXPECT_FALSE(retained->getAttackTarget()); + EXPECT_EQ(script::kObjectInvalid, retained->getAttemptedAttackTarget()); + EXPECT_EQ(script::kObjectInvalid, retained->getLastHostileActor()); + EXPECT_EQ(0u, TestGameModule::seenObjectCount(*retained)); + EXPECT_EQ(0u, TestGameModule::heardObjectCount(*retained)); + EXPECT_EQ(script::kObjectInvalid, retained->blockingDoorId()); + EXPECT_TRUE(retained->actions().empty()); + EXPECT_TRUE(action->isCancelled()); + EXPECT_EQ(0u, TestGameModule::delayedActionCount(*retained)); + EXPECT_FALSE(retained->isStuntMode()); + + EXPECT_EQ(19, retained->currentHitPoints()); + EXPECT_TRUE(retained->getLocalBoolean(3)); + EXPECT_EQ(23, retained->getLocalNumber(4)); + EXPECT_EQ(42, retained->appearance()); + ASSERT_EQ(1u, retained->items().size()); + EXPECT_EQ(carried, retained->items().front()); + ASSERT_EQ(5u, retained->effects().size()); + EXPECT_EQ(durableEffect, retained->effects().front().effect); + EXPECT_EQ(1, retained->modifiedAttacks()); + EXPECT_TRUE(retained->hasAssuredHit()); + EXPECT_FALSE(retained->effects().back().boundCreator()); + EXPECT_EQ(kSavedEffectInvalidObjectId, retained->effects().back().creatorId); + EXPECT_FALSE(retained->effects().back().boundObjectParameter(0)); + EXPECT_EQ( + kSavedEffectInvalidObjectId, + retained->effects().back().objectParameters[0]); + EXPECT_EQ(retained, game.party().rosterCreature({RosterKind::Npc, 0})); + + const auto &creatures = area->getObjectsByType(ObjectType::Creature); + EXPECT_EQ(creatures.end(), std::find(creatures.begin(), creatures.end(), retained)); + EXPECT_NE(creatures.end(), std::find(creatures.begin(), creatures.end(), outgoing)); + + area->destroyObject(*outgoing); + area->update(0.0f); + EXPECT_FALSE(game.getObjectById(outgoing->id())); + outgoing.reset(); + beamEffect.reset(); + EXPECT_TRUE(outgoingEffectTarget.expired()); +} + +TEST(RuntimeObjectIntegrity, creature_inventory_and_equipment_replace_as_one_graph) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + EXPECT_CALL(engine.resourceModule().twoDas(), get("appearance")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeAppearances())); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + EXPECT_CALL( + static_cast(engine.services().game.portraits), + getTextureByAppearance(_)) + .Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + + auto creature = game.newCreature(); + auto initial = resource::Gff::Builder() + .field(resource::Gff::Field::newList( + "ItemList", {runtimeItem("old_inventory", 810)})) + .field(resource::Gff::Field::newList( + "Equip_ItemList", + {runtimeItem("old_equipment", 811, 1u << InventorySlots::body)})) + .build(); + creature->deserialize( + *initial, SerializedIdentityContext::moduleGraph("replacement")); + auto oldInventory = creature->items().front(); + auto oldEquipment = creature->getEquippedItem(InventorySlots::body); + ASSERT_TRUE(oldEquipment); + const size_t registrySize = TestGameModule::objectRegistrySize(game); + + auto saved = resource::Gff::Builder() + .field(resource::Gff::Field::newList( + "ItemList", {runtimeItem("new_inventory", 810)})) + .field(resource::Gff::Field::newList( + "Equip_ItemList", + {runtimeItem("new_equipment", 811, 1u << InventorySlots::body)})) + .build(); + creature->deserialize( + *saved, SerializedIdentityContext::moduleGraph("replacement")); + + EXPECT_FALSE(game.getObjectById(oldInventory->id())); + EXPECT_FALSE(game.getObjectById(oldEquipment->id())); + ASSERT_EQ(1u, creature->items().size()); + ASSERT_TRUE(creature->getEquippedItem(InventorySlots::body)); + EXPECT_EQ("new_inventory", creature->items().front()->tag()); + EXPECT_EQ("new_equipment", + creature->getEquippedItem(InventorySlots::body)->tag()); + EXPECT_EQ(creature->items().front(), game.getObjectBySavedId(810)); + EXPECT_EQ(creature->getEquippedItem(InventorySlots::body), + game.getObjectBySavedId(811)); + EXPECT_EQ(registrySize, TestGameModule::objectRegistrySize(game)); +} + +TEST(RuntimeObjectIntegrity, savewide_party_inventory_replacement_retires_old_items) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto player = game.newCreature(); + game.party().addMember(kNpcPlayer, player); + game.party().setPlayer(player); + game.party().setActualPlayer(player); + auto oldItem = game.newItem(); + player->addItem(oldItem); + auto inventory = resource::Gff::Builder() + .field(resource::Gff::Field::newList( + "ItemList", {runtimeItem("shared_new")})) + .build(); + + TestGameModule::deserializeInventory(game, *inventory); + + EXPECT_FALSE(game.getObjectById(oldItem->id())); + ASSERT_EQ(1u, player->items().size()); + EXPECT_EQ(player->items().front(), + game.getObjectById(player->items().front()->id())); + ASSERT_TRUE(player->items().front()->saveRecordProvenance()); + EXPECT_EQ(SaveRecordOriginKind::PartyInventoryItem, + player->items().front()->saveRecordProvenance()->origin.kind); +} + +TEST(RuntimeObjectIntegrity, savewide_party_inventory_coalesces_records_without_object_ids) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto player = game.newCreature(); + game.party().addMember(kNpcPlayer, player); + game.party().setPlayer(player); + game.party().setActualPlayer(player); + auto inventory = resource::Gff::Builder() + .field(resource::Gff::Field::newList( + "ItemList", + {runtimeItem("computer_spike", std::nullopt, 0, 2), + runtimeItem("computer_spike", std::nullopt, 0, 3)})) + .build(); + + TestGameModule::deserializeInventory(game, *inventory); + + ASSERT_EQ(1u, player->items().size()); + EXPECT_EQ(5, player->items().front()->stackSize()); + EXPECT_EQ(player->id(), player->items().front()->owner()); +} + +TEST(RuntimeObjectIntegrity, failed_graph_replacement_preserves_old_graph_and_cursor) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + + auto creature = game.newCreature(); + auto oldEquipment = game.newItem(); + oldEquipment->deserialize( + *runtimeItem("old_equipment"), + SerializedIdentityContext::templateResource()); + ASSERT_TRUE(creature->equip(InventorySlots::body, oldEquipment)); + const size_t registrySize = TestGameModule::objectRegistrySize(game); + const uint32_t nextId = TestGameModule::nextObjectId(game); + + auto saved = resource::Gff::Builder() + .field(resource::Gff::Field::newList( + "Equip_ItemList", + {runtimeItem("candidate_a", 820, 1u << InventorySlots::body), + runtimeItem("candidate_b", 821, 1u << InventorySlots::body)})) + .build(); + EXPECT_THROW( + creature->deserialize( + *saved, SerializedIdentityContext::moduleGraph("failure")), + ValidationException); + + EXPECT_EQ(oldEquipment, creature->getEquippedItem(InventorySlots::body)); + EXPECT_EQ(oldEquipment, game.getObjectById(oldEquipment->id())); + EXPECT_FALSE(game.getObjectBySavedId(820)); + EXPECT_FALSE(game.getObjectBySavedId(821)); + EXPECT_EQ(registrySize, TestGameModule::objectRegistrySize(game)); + EXPECT_EQ(nextId, TestGameModule::nextObjectId(game)); +} + +TEST(RuntimeObjectIntegrity, store_and_placeable_replace_owned_items_immediately) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + EXPECT_CALL(engine.resourceModule().twoDas(), get("placeables")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimePlaceables())); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + + auto store = game.newStore(); + auto oldStoreItem = game.newItem(); + store->addItem(oldStoreItem); + auto storeSaved = resource::Gff::Builder() + .field(resource::Gff::Field::newList( + "ItemList", {runtimeItem("store_new")})) + .build(); + store->deserialize( + *storeSaved, SerializedIdentityContext::detachedRecord("store")); + EXPECT_FALSE(game.getObjectById(oldStoreItem->id())); + ASSERT_EQ(1u, store->items().size()); + EXPECT_EQ(store->items().front(), + game.getObjectById(store->items().front()->id())); + + auto placeable = game.newPlaceable(); + auto oldPlaceableItem = game.newItem(); + placeable->addItem(oldPlaceableItem); + auto placeableSaved = resource::Gff::Builder() + .field(resource::Gff::Field::newDword("Appearance", 0)) + .field(resource::Gff::Field::newList( + "ItemList", {runtimeItem("placeable_new")})) + .build(); + placeable->deserialize( + *placeableSaved, + SerializedIdentityContext::detachedRecord("placeable")); + EXPECT_FALSE(game.getObjectById(oldPlaceableItem->id())); + ASSERT_EQ(1u, placeable->items().size()); + EXPECT_EQ(placeable->items().front(), + game.getObjectById(placeable->items().front()->id())); +} + +TEST(RuntimeObjectIntegrity, staged_template_container_coalesces_item_records) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + EXPECT_CALL(engine.resourceModule().twoDas(), get("placeables")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimePlaceables())); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto placeable = game.newPlaceable(); + const size_t registrySize = TestGameModule::objectRegistrySize(game); + auto record = resource::Gff::Builder() + .field(resource::Gff::Field::newDword("Appearance", 0)) + .field(resource::Gff::Field::newList( + "ItemList", + {runtimeItem("computer_spike"), + runtimeItem("computer_spike"), + runtimeItem("computer_spike"), + runtimeItem("computer_spike"), + runtimeItem("computer_spike")})) + .build(); + + placeable->deserialize( + *record, + SerializedIdentityContext::templateResource("endar_footlocker")); + + ASSERT_EQ(1u, placeable->items().size()); + EXPECT_EQ(5, placeable->items().front()->stackSize()); + EXPECT_EQ(placeable->id(), placeable->items().front()->owner()); + EXPECT_TRUE(placeable->items().front()->isRuntimeLive()); + EXPECT_EQ(registrySize + 1, TestGameModule::objectRegistrySize(game)); +} + +TEST(RuntimeObjectIntegrity, staged_creature_and_detached_inventory_coalesce) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + EXPECT_CALL(engine.resourceModule().twoDas(), get("appearance")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeAppearances())); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + EXPECT_CALL( + static_cast(engine.services().game.portraits), + getTextureByAppearance(_)) + .Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto creature = game.newCreature(); + auto record = resource::Gff::Builder() + .field(resource::Gff::Field::newList( + "ItemList", + {runtimeItem("repair_part", std::nullopt, 0, 2), + runtimeItem("repair_part", std::nullopt, 0, 3)})) + .build(); + + creature->deserialize( + *record, + SerializedIdentityContext::detachedRecord("availnpc0.utc")); + + ASSERT_EQ(1u, creature->items().size()); + EXPECT_EQ(5, creature->items().front()->stackSize()); + ASSERT_TRUE(creature->items().front()->saveRecordProvenance()); + EXPECT_FALSE(creature->items().front()->serializedObjectIdentity()); +} + +TEST(RuntimeObjectIntegrity, staged_stack_coalescing_obeys_retail_maximum) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems(10))); + EXPECT_CALL(engine.resourceModule().twoDas(), get("placeables")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimePlaceables())); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto load = [&](std::vector> items) { + auto placeable = game.newPlaceable(); + auto record = resource::Gff::Builder() + .field(resource::Gff::Field::newDword("Appearance", 0)) + .field(resource::Gff::Field::newList( + "ItemList", std::move(items))) + .build(); + placeable->deserialize( + *record, + SerializedIdentityContext::templateResource("max_stack")); + return placeable; + }; + + auto below = load( + {runtimeItem("parts", std::nullopt, 0, 4), + runtimeItem("parts", std::nullopt, 0, 5)}); + ASSERT_EQ(1u, below->items().size()); + EXPECT_EQ(9, below->items()[0]->stackSize()); + + auto exact = load( + {runtimeItem("parts", std::nullopt, 0, 4), + runtimeItem("parts", std::nullopt, 0, 6)}); + ASSERT_EQ(1u, exact->items().size()); + EXPECT_EQ(10, exact->items()[0]->stackSize()); + + auto above = load( + {runtimeItem("parts", std::nullopt, 0, 6), + runtimeItem("parts", std::nullopt, 0, 6), + runtimeItem("parts", std::nullopt, 0, 6)}); + ASSERT_EQ(2u, above->items().size()); + EXPECT_EQ(10, above->items()[0]->stackSize()); + EXPECT_EQ(8, above->items()[1]->stackSize()); + + auto several = load( + {runtimeItem("parts", std::nullopt, 0, 6), + runtimeItem("parts", std::nullopt, 0, 6), + runtimeItem("parts", std::nullopt, 0, 6), + runtimeItem("parts", std::nullopt, 0, 6), + runtimeItem("parts", std::nullopt, 0, 6), + runtimeItem("parts", std::nullopt, 0, 1)}); + ASSERT_EQ(4u, several->items().size()); + EXPECT_EQ(10, several->items()[0]->stackSize()); + EXPECT_EQ(10, several->items()[1]->stackSize()); + EXPECT_EQ(10, several->items()[2]->stackSize()); + EXPECT_EQ(1, several->items()[3]->stackSize()); +} + +TEST(RuntimeObjectIntegrity, staged_authoritative_item_identities_remain_distinct) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + EXPECT_CALL(engine.resourceModule().twoDas(), get("placeables")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimePlaceables())); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto placeable = game.newPlaceable(); + auto record = resource::Gff::Builder() + .field(resource::Gff::Field::newDword("Appearance", 0)) + .field(resource::Gff::Field::newList( + "ItemList", + {runtimeItem("saved_stack", 870), + runtimeItem("saved_stack", 871)})) + .build(); + + placeable->deserialize( + *record, SerializedIdentityContext::moduleGraph("saved_inventory")); + + ASSERT_EQ(2u, placeable->items().size()); + EXPECT_EQ(placeable->items()[0], game.getObjectBySavedId(870)); + EXPECT_EQ(placeable->items()[1], game.getObjectBySavedId(871)); + EXPECT_NE(placeable->items()[0], placeable->items()[1]); + + auto detached = game.newPlaceable(); + auto detachedRecord = resource::Gff::Builder() + .field(resource::Gff::Field::newDword("Appearance", 0)) + .field(resource::Gff::Field::newList( + "ItemList", + {runtimeItem("detached_stack", 880), + runtimeItem("detached_stack", 881)})) + .build(); + const auto detachedContext = + SerializedIdentityContext::detachedRecord("inventory.res"); + + detached->deserialize(*detachedRecord, detachedContext); + + ASSERT_EQ(2u, detached->items().size()); + EXPECT_EQ( + detached->items()[0]->serializedObjectIdentity(), + std::optional({detachedContext, 880})); + EXPECT_EQ( + detached->items()[1]->serializedObjectIdentity(), + std::optional({detachedContext, 881})); + EXPECT_FALSE(game.getObjectBySavedId(880)); + EXPECT_FALSE(game.getObjectBySavedId(881)); +} + +TEST(RuntimeObjectIntegrity, staged_stack_compatibility_preserves_distinct_state_and_equipment) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + EXPECT_CALL(engine.resourceModule().twoDas(), get("appearance")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeAppearances())); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + EXPECT_CALL( + static_cast(engine.services().game.portraits), + getTextureByAppearance(_)) + .Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto creature = game.newCreature(); + auto record = resource::Gff::Builder() + .field(resource::Gff::Field::newList( + "ItemList", + {runtimeItem("same_tag", std::nullopt, 0, 1, 0), + runtimeItem("same_tag", std::nullopt, 0, 1, 1)})) + .field(resource::Gff::Field::newList( + "Equip_ItemList", + {runtimeItem( + "same_tag", + std::nullopt, + 1u << InventorySlots::body)})) + .build(); + + creature->deserialize( + *record, SerializedIdentityContext::templateResource("creature_items")); + + ASSERT_EQ(2u, creature->items().size()); + ASSERT_TRUE(creature->getEquippedItem(InventorySlots::body)); + EXPECT_TRUE(creature->getEquippedItem(InventorySlots::body)->isEquipped()); + EXPECT_EQ(creature->id(), creature->getEquippedItem(InventorySlots::body)->owner()); +} + +TEST(RuntimeObjectIntegrity, area_destruction_ends_registry_and_saved_visibility) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + EXPECT_CALL(engine.resourceModule().twoDas(), get("appearance")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeAppearances())); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + EXPECT_CALL( + static_cast(engine.services().game.portraits), + getTextureByAppearance(_)) + .Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto area = game.newArea(); + auto saved = resource::Gff::Builder() + .field(resource::Gff::Field::newDword("ObjectId", 830)) + .build(); + auto object = game.newCreature( + *saved, SerializedIdentityContext::moduleGraph("destroy")); + area->add(object); + const uint32_t runtimeId = object->id(); + + area->destroyObject(*object); + area->update(0.0f); + + EXPECT_FALSE(game.getObjectById(runtimeId)); + EXPECT_FALSE(game.getObjectBySavedId(830)); + EXPECT_FALSE(game.isRuntimeObjectLive(*object)); + EXPECT_TRUE(area->getObjectsByType(ObjectType::Creature).empty()); + game.destroyRuntimeObjectGraph(object); + EXPECT_FALSE(game.getObjectById(runtimeId)); +} + +TEST(RuntimeObjectIntegrity, stale_finalization_cannot_erase_a_newer_id_owner) { + TestEngine engine; + engine.init(); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto stale = game.newItem(); + game.destroyRuntimeObjectGraph(stale); + auto replacement = game.newItem(); + TestGameModule::setSnapshotObjectId(*stale, replacement->id()); + + game.destroyRuntimeObjectGraph(stale); + + EXPECT_EQ(replacement, game.getObjectById(replacement->id())); + EXPECT_TRUE(game.isRuntimeObjectLive(*replacement)); +} + +TEST(RuntimeObjectIntegrity, area_retirement_preserves_session_object_liveness) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + StubConsole console; + Game game(resource::GameID::TSL, "", engine.options(), engine.services(), console); + auto area = game.newArea(); + auto retained = game.newCreature(); + game.party().addMember(kNpcPlayer, retained); + game.party().setPlayer(retained); + game.party().setActualPlayer(retained); + area->add(retained); + + area->retireCreatureAreaRuntime(retained); + + EXPECT_EQ(retained, game.getObjectById(retained->id())); + EXPECT_TRUE(game.isRuntimeObjectLive(*retained)); + EXPECT_TRUE(area->getObjectsByType(ObjectType::Creature).empty()); +} + +TEST(RuntimeObjectLiveness, destroyed_strong_action_target_cannot_execute) { + TestEngine engine; + engine.init(); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto actor = game.newCreature(); + auto target = game.newCreature(); + int executions = 0; + auto action = game.newAction(target, executions); + actor->addAction(action); + + game.destroyRuntimeObjectGraph(target); + ASSERT_TRUE(target); + EXPECT_FALSE(target->isRuntimeLive()); + actor->update(0.0f); + + EXPECT_EQ(0, executions); + EXPECT_TRUE(action->isCompleted()); +} + +TEST(RuntimeObjectLiveness, moved_action_target_still_tracks_exact_liveness) { + TestEngine engine; + engine.init(); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto target = game.newCreature(); + auto action = game.newAction( + SpellType::AffectMind, target, ProjectilePathType::Default); + + ASSERT_TRUE(action->runtimeDependenciesLive()); + game.destroyRuntimeObjectGraph(target); + + EXPECT_FALSE(action->runtimeDependenciesLive()); +} + +TEST(RuntimeObjectLiveness, stale_reference_does_not_alias_reused_session_id) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto first = game.newItem(); + RuntimeObjectRef stale(first); + const uint32_t reusedId = first->id(); + const uint64_t incarnation = first->runtimeIncarnation(); + + game.retireRuntimeSession(); + auto second = game.newItem(); + + ASSERT_EQ(reusedId, second->id()); + EXPECT_NE(incarnation, second->runtimeIncarnation()); + EXPECT_FALSE(stale.resolve()); + EXPECT_EQ(second, game.getObjectById(reusedId)); +} + +TEST(RuntimeObjectLiveness, published_runtime_id_is_not_reused_within_session) { + TestEngine engine; + engine.init(); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto observer = game.newCreature(); + auto hostile = game.newCreature(); + const uint32_t hostileId = hostile->id(); + observer->setLastHostileActor(hostileId); + + ASSERT_EQ(hostileId, observer->getLastHostileActor()); + game.destroyRuntimeObjectGraph(hostile); + + EXPECT_EQ(script::kObjectInvalid, observer->getLastHostileActor()); + EXPECT_THROW( + TestGameModule::newItemAtRuntimeId(game, hostileId), + ValidationException); + + auto ifo = resource::Gff::Builder() + .field(resource::Gff::Field::newDword( + "Mod_NextObjId0", hostileId)) + .build(); + game.prepareSavedRuntimeNamespace( + *ifo, SerializedIdentityContext::moduleGraph("reuse_guard")); + auto replacement = game.newItem(); + + EXPECT_NE(hostileId, replacement->id()); + EXPECT_EQ(replacement->id(), game.getObjectById(replacement->id())->id()); +} + +TEST(RuntimeObjectLiveness, effect_and_vm_event_references_fail_closed) { + TestEngine engine; + engine.init(); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto target = game.newCreature(); + + EffectInstance effect; + effect.creatorId = target->id(); + effect.objectParameters[0] = target->id(); + ASSERT_TRUE(game.bindEffectCreator(effect)); + ASSERT_EQ(target, effect.boundCreator()); + ASSERT_EQ(target, effect.boundObjectParameter(0)); + Event event(1, {}, {}, {}, {target}); + + game.destroyRuntimeObjectGraph(target); + + EXPECT_FALSE(effect.boundCreator()); + EXPECT_FALSE(effect.boundObjectParameter(0)); + EXPECT_EQ( + event.objects(), + (std::vector {script::kObjectInvalid})); +} + +TEST(RuntimeObjectLiveness, flat_saved_reference_fails_closed_after_target_death) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + const auto context = + SerializedIdentityContext::moduleGraph("flat_reference"); + auto sourceRecord = resource::Gff::Builder() + .field(resource::Gff::Field::newDword("ObjectId", 850)) + .field(resource::Gff::Field::newInt("BaseItem", 0)) + .field(resource::Gff::Field::newDword("CreatorId", 851)) + .build(); + auto targetRecord = resource::Gff::Builder() + .field(resource::Gff::Field::newDword("ObjectId", 851)) + .field(resource::Gff::Field::newInt("BaseItem", 0)) + .build(); + auto source = game.newItem(*sourceRecord, context); + auto target = game.newItem(*targetRecord, context); + game.resolveSavedObjectReferences(); + ASSERT_EQ(target, source->savedReference("CreatorId")); + + game.destroyRuntimeObjectGraph(target); + + EXPECT_FALSE(source->savedReference("CreatorId")); +} + +TEST(RuntimeObjectIntegrity, gff_factory_failure_never_publishes_candidate) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + EXPECT_CALL(engine.resourceModule().twoDas(), get("appearance")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeAppearances())); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + EXPECT_CALL( + static_cast(engine.services().game.portraits), + getTextureByAppearance(_)) + .Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + const size_t registrySize = TestGameModule::objectRegistrySize(game); + const uint32_t nextId = TestGameModule::nextObjectId(game); + auto invalid = resource::Gff::Builder() + .field(resource::Gff::Field::newDword("ObjectId", 840)) + .field(resource::Gff::Field::newList( + "Equip_ItemList", + {runtimeItem("candidate_a", 841, 1u << InventorySlots::body), + runtimeItem("candidate_b", 842, 1u << InventorySlots::body)})) + .build(); + + EXPECT_THROW( + game.newCreature( + *invalid, + SerializedIdentityContext::moduleGraph("factory_failure")), + ValidationException); + + EXPECT_EQ(registrySize, TestGameModule::objectRegistrySize(game)); + EXPECT_EQ(nextId, TestGameModule::nextObjectId(game)); + EXPECT_FALSE(game.getObjectBySavedId(840)); + EXPECT_FALSE(game.getObjectBySavedId(841)); + EXPECT_FALSE(game.getObjectBySavedId(842)); +} + +TEST(RuntimeObjectOwnership, stack_merge_finalizes_consumed_item) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto owner = game.newCreature(); + auto retained = game.newItem(); + retained->deserialize( + *runtimeItem("stack"), + SerializedIdentityContext::templateResource()); + auto consumed = game.newItem(); + consumed->deserialize( + *runtimeItem("stack"), + SerializedIdentityContext::templateResource()); + owner->addItem(retained); + const size_t registrySize = TestGameModule::objectRegistrySize(game); + + owner->addItem(consumed); + + ASSERT_EQ(1u, owner->items().size()); + EXPECT_EQ(retained, owner->items().front()); + EXPECT_EQ(2, retained->stackSize()); + EXPECT_FALSE(consumed->isRuntimeLive()); + EXPECT_FALSE(game.getObjectById(consumed->id())); + EXPECT_EQ(registrySize - 1, TestGameModule::objectRegistrySize(game)); +} + +TEST(RuntimeObjectOwnership, runtime_stack_merge_uses_the_retail_maximum) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems(10))); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto owner = game.newCreature(); + auto retained = game.newItem(); + retained->deserialize( + *runtimeItem("stack", std::nullopt, 0, 8), + SerializedIdentityContext::templateResource()); + auto remainder = game.newItem(); + remainder->deserialize( + *runtimeItem("stack", std::nullopt, 0, 5), + SerializedIdentityContext::templateResource()); + owner->addItem(retained); + + owner->addItem(remainder); + + ASSERT_EQ(2u, owner->items().size()); + EXPECT_EQ(10, retained->stackSize()); + EXPECT_EQ(3, remainder->stackSize()); + EXPECT_EQ(owner->id(), retained->owner()); + EXPECT_EQ(owner->id(), remainder->owner()); + EXPECT_TRUE(retained->isRuntimeLive()); + EXPECT_TRUE(remainder->isRuntimeLive()); +} + +TEST(RuntimeObjectOwnership, equipment_replacement_disposes_previous_item) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto owner = game.newCreature(); + auto first = game.newItem(); + first->deserialize( + *runtimeItem("first"), + SerializedIdentityContext::templateResource()); + auto replacement = game.newItem(); + replacement->deserialize( + *runtimeItem("replacement"), + SerializedIdentityContext::templateResource()); + ASSERT_TRUE(owner->equip(InventorySlots::body, first)); + + ASSERT_TRUE(owner->replaceEquipment( + InventorySlots::body, replacement, *owner)); + + EXPECT_EQ(replacement, owner->getEquippedItem(InventorySlots::body)); + EXPECT_EQ(owner->id(), replacement->owner()); + ASSERT_EQ(1u, owner->items().size()); + EXPECT_EQ(first, owner->items().front()); + EXPECT_EQ(owner->id(), first->owner()); + EXPECT_FALSE(first->isEquipped()); + EXPECT_TRUE(first->isRuntimeLive()); +} + +TEST(RuntimeObjectOwnership, unequip_moves_item_to_explicit_receiver) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto owner = game.newCreature(); + auto item = game.newItem(); + item->deserialize( + *runtimeItem("unequip"), + SerializedIdentityContext::templateResource()); + ASSERT_TRUE(owner->equip(InventorySlots::body, item)); + EXPECT_THROW(owner->addItem(item), ValidationException); + + ASSERT_TRUE(owner->moveEquippedItemTo(item, *owner)); + + EXPECT_FALSE(item->isEquipped()); + EXPECT_EQ(owner->id(), item->owner()); + EXPECT_FALSE(owner->getEquippedItem(InventorySlots::body)); + ASSERT_EQ(1u, owner->items().size()); + EXPECT_EQ(item, owner->items().front()); +} + +TEST(RuntimeObjectOwnership, transfers_equipped_item_between_creatures) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto source = game.newCreature(); + auto receiver = game.newCreature(); + auto item = game.newItem(); + item->deserialize( + *runtimeItem("transfer"), + SerializedIdentityContext::templateResource()); + ASSERT_TRUE(source->equip(InventorySlots::body, item)); + + ASSERT_TRUE(transferItemTo(game, item, *receiver)); + + EXPECT_FALSE(source->getEquippedItem(InventorySlots::body)); + EXPECT_TRUE(source->items().empty()); + ASSERT_EQ(1u, receiver->items().size()); + EXPECT_EQ(item, receiver->items().front()); + EXPECT_EQ(receiver->id(), item->owner()); + EXPECT_FALSE(item->isEquipped()); + EXPECT_TRUE(item->isRuntimeLive()); +} + +TEST(RuntimeObjectOwnership, give_item_moves_created_world_item_into_inventory) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + EXPECT_CALL( + engine.resourceModule().gffs(), + get("g_i_medeqpmnt04", resource::ResType::Uti)) + .WillOnce(Return(runtimeItem("g_i_medeqpmnt04"))); + StubConsole console; + Game game(resource::GameID::TSL, "", engine.options(), engine.services(), console); + auto area = game.newArea(); + TestGameModule::setActiveModuleArea(game, area); + auto pc = game.newCreature(); + ASSERT_TRUE(game.party().addMember(kNpcPlayer, pc)); + game.party().setPlayer(pc); + game.party().setActualPlayer(pc); + + auto created = area->createObject( + ObjectType::Item, + "g_i_medeqpmnt04", + game.newLocation(glm::vec3(1.0f, 2.0f, 3.0f), 0.0f)); + auto item = std::dynamic_pointer_cast(created); + ASSERT_TRUE(item); + item->setStackSize(3); + ASSERT_NE( + area->objects().end(), + std::find(area->objects().begin(), area->objects().end(), item)); + + giveItemThroughRoutine(engine, game, item, pc); + + ASSERT_EQ(1u, pc->items().size()); + EXPECT_EQ(item, pc->items().front()); + EXPECT_EQ(3, item->stackSize()); + EXPECT_EQ(pc->id(), item->owner()); + EXPECT_TRUE(item->isRuntimeLive()); + EXPECT_EQ(item, game.getObjectById(item->id())); + EXPECT_EQ( + area->objects().end(), + std::find(area->objects().begin(), area->objects().end(), item)); + EXPECT_TRUE(area->getObjectsByType(ObjectType::Item).empty()); + EXPECT_FALSE(area->getObjectByTag("g_i_medeqpmnt04")); + EXPECT_EQ(nullptr, item->room()); +} + +TEST(RuntimeObjectOwnership, area_item_stack_merge_retires_only_incoming_item) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto area = game.newArea(); + TestGameModule::setActiveModuleArea(game, area); + auto receiver = game.newCreature(); + auto existing = game.newItem(); + existing->deserialize( + *runtimeItem("world_stack"), + SerializedIdentityContext::templateResource()); + existing->setStackSize(2); + receiver->addItem(existing); + auto incoming = game.newItem(); + incoming->deserialize( + *runtimeItem("world_stack"), + SerializedIdentityContext::templateResource()); + incoming->setStackSize(3); + area->add(incoming); + const uint32_t incomingId = incoming->id(); + + ASSERT_TRUE(transferItemTo(game, incoming, *receiver)); + + ASSERT_EQ(1u, receiver->items().size()); + EXPECT_EQ(existing, receiver->items().front()); + EXPECT_EQ(5, existing->stackSize()); + EXPECT_EQ(receiver->id(), existing->owner()); + EXPECT_TRUE(existing->isRuntimeLive()); + EXPECT_FALSE(incoming->isRuntimeLive()); + EXPECT_FALSE(game.getObjectById(incomingId)); + EXPECT_EQ( + area->objects().end(), + std::find(area->objects().begin(), area->objects().end(), incoming)); + EXPECT_TRUE(area->getObjectsByType(ObjectType::Item).empty()); + EXPECT_FALSE(area->getObjectByTag("world_stack")); +} + +TEST(RuntimeObjectOwnership, git_shaped_world_item_releases_every_area_edge) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto area = game.newArea(); + TestGameModule::setActiveModuleArea(game, area); + auto trigger = game.newTrigger(); + trigger->deserialize( + *resource::Gff::Builder().build(), + SerializedIdentityContext::templateResource()); + area->add(trigger); + auto item = game.newItem( + *runtimeItem("git_world"), + SerializedIdentityContext::templateResource("test-area")); + + auto root = std::make_shared( + 0, "world_item", glm::vec3(0.0f), + glm::quat(1.0f, 0.0f, 0.0f, 0.0f), true, nullptr); + auto model = std::make_unique( + "world_item", 0, root, + std::vector>(), "", 1.0f); + auto sceneNode = std::make_shared( + *model, scene::ModelUsage::Placeable, sceneGraph, + engine.graphicsModule().services(), + engine.audioModule().services(), + engine.resourceModule().services()); + TestGameModule::setAreaRuntimeSceneNode(*item, sceneNode); + EXPECT_CALL(sceneGraph, addRoot(sceneNode)).Times(1); + EXPECT_CALL( + sceneGraph, + removeRoot(A())) + .WillOnce([&sceneNode](scene::ModelSceneNode &removed) { + EXPECT_EQ(sceneNode.get(), &removed); + }); + area->add(item); + Room room("world_room", glm::vec3(0.0f), nullptr, nullptr, nullptr); + item->setRoom(&room); + trigger->addTenant(item); + auto receiver = game.newCreature(); + + ASSERT_TRUE(transferItemTo(game, item, *receiver)); + + ASSERT_EQ(1u, receiver->items().size()); + EXPECT_EQ(item, receiver->items().front()); + EXPECT_EQ(receiver->id(), item->owner()); + EXPECT_TRUE(item->isRuntimeLive()); + EXPECT_EQ(nullptr, item->room()); + EXPECT_FALSE(trigger->isTenant(item)); + EXPECT_EQ( + area->objects().end(), + std::find(area->objects().begin(), area->objects().end(), item)); + EXPECT_TRUE(area->getObjectsByType(ObjectType::Item).empty()); + EXPECT_FALSE(area->getObjectByTag("git_world")); +} + +TEST(RuntimeObjectOwnership, ownerless_nonresident_item_is_not_transferable) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto area = game.newArea(); + TestGameModule::setActiveModuleArea(game, area); + auto item = game.newItem(); + item->deserialize( + *runtimeItem("unowned"), + SerializedIdentityContext::templateResource()); + auto receiver = game.newCreature(); + + EXPECT_FALSE(transferItemTo(game, item, *receiver)); + + EXPECT_TRUE(receiver->items().empty()); + EXPECT_EQ(0u, item->owner()); + EXPECT_TRUE(item->isRuntimeLive()); + EXPECT_EQ(item, game.getObjectById(item->id())); +} + +TEST(RuntimeObjectOwnership, equip_action_releases_area_item_before_equipping) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto area = game.newArea(); + TestGameModule::setActiveModuleArea(game, area); + auto actor = game.newCreature(); + area->add(actor); + auto trigger = game.newTrigger(); + trigger->deserialize( + *resource::Gff::Builder().build(), + SerializedIdentityContext::templateResource()); + area->add(trigger); + auto item = game.newItem( + *runtimeItem("world_equip"), + SerializedIdentityContext::templateResource("test-area")); + const auto runtimeId = item->id(); + const auto incarnation = item->runtimeIncarnation(); + + auto root = std::make_shared( + 0, "world_equip", glm::vec3(0.0f), + glm::quat(1.0f, 0.0f, 0.0f, 0.0f), true, nullptr); + auto model = std::make_unique( + "world_equip", 0, root, + std::vector>(), "", 1.0f); + auto sceneNode = std::make_shared( + *model, scene::ModelUsage::Placeable, sceneGraph, + engine.graphicsModule().services(), + engine.audioModule().services(), + engine.resourceModule().services()); + TestGameModule::setAreaRuntimeSceneNode(*item, sceneNode); + EXPECT_CALL(sceneGraph, addRoot(sceneNode)).Times(1); + EXPECT_CALL(sceneGraph, removeRoot(A())) + .WillOnce([&sceneNode](scene::ModelSceneNode &removed) { + EXPECT_EQ(sceneNode.get(), &removed); + }); + area->add(item); + Room room("world_room", glm::vec3(0.0f), nullptr, nullptr, nullptr); + item->setRoom(&room); + trigger->addTenant(item); + auto action = game.newAction( + item, InventorySlots::body, true); + + action->execute(action, *actor, 0.0f); + + EXPECT_EQ(item, actor->getEquippedItem(InventorySlots::body)); + EXPECT_EQ(actor->id(), item->owner()); + EXPECT_TRUE(item->isEquipped()); + EXPECT_EQ(nullptr, item->room()); + EXPECT_FALSE(trigger->isTenant(item)); + EXPECT_EQ( + area->objects().end(), + std::find(area->objects().begin(), area->objects().end(), item)); + EXPECT_TRUE(area->getObjectsByType(ObjectType::Item).empty()); + EXPECT_FALSE(area->getObjectByTag("world_equip")); + EXPECT_TRUE(item->isRuntimeLive()); + EXPECT_EQ(runtimeId, item->id()); + EXPECT_EQ(incarnation, item->runtimeIncarnation()); + EXPECT_EQ(item, game.getObjectById(runtimeId)); +} + +TEST(RuntimeObjectOwnership, equip_action_replaces_slot_with_area_item) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto area = game.newArea(); + TestGameModule::setActiveModuleArea(game, area); + auto actor = game.newCreature(); + area->add(actor); + auto displaced = game.newItem(); + displaced->deserialize( + *runtimeItem("displaced"), + SerializedIdentityContext::templateResource()); + ASSERT_TRUE(actor->equip(InventorySlots::body, displaced)); + auto incoming = game.newItem(); + incoming->deserialize( + *runtimeItem("incoming"), + SerializedIdentityContext::templateResource()); + area->add(incoming); + auto action = game.newAction( + incoming, InventorySlots::body, true); + + action->execute(action, *actor, 0.0f); + + EXPECT_EQ(incoming, actor->getEquippedItem(InventorySlots::body)); + EXPECT_EQ(actor->id(), incoming->owner()); + EXPECT_TRUE(incoming->isEquipped()); + EXPECT_EQ( + area->objects().end(), + std::find(area->objects().begin(), area->objects().end(), incoming)); + EXPECT_TRUE(area->getObjectsByType(ObjectType::Item).empty()); + ASSERT_EQ(1u, actor->items().size()); + EXPECT_EQ(displaced, actor->items().front()); + EXPECT_EQ(actor->id(), displaced->owner()); + EXPECT_FALSE(displaced->isEquipped()); + EXPECT_TRUE(displaced->isRuntimeLive()); +} + +TEST(RuntimeObjectOwnership, rejected_area_item_equip_preserves_area_ownership) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto area = game.newArea(); + TestGameModule::setActiveModuleArea(game, area); + auto actor = game.newCreature(); + area->add(actor); + auto item = game.newItem(); + item->deserialize( + *runtimeItem("wrong_slot"), + SerializedIdentityContext::templateResource()); + area->add(item); + auto action = game.newAction( + item, InventorySlots::rightWeapon, true); + + action->execute(action, *actor, 0.0f); + + EXPECT_FALSE(actor->getEquippedItem(InventorySlots::rightWeapon)); + EXPECT_EQ(0u, item->owner()); + EXPECT_FALSE(item->isEquipped()); + EXPECT_NE( + area->objects().end(), + std::find(area->objects().begin(), area->objects().end(), item)); + ASSERT_EQ(1u, area->getObjectsByType(ObjectType::Item).size()); + EXPECT_EQ(item, area->getObjectsByType(ObjectType::Item).front()); + EXPECT_EQ(item, area->getObjectByTag("wrong_slot")); +} + +TEST(RuntimeObjectOwnership, equip_action_rejects_ownerless_nonresident_item) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto area = game.newArea(); + TestGameModule::setActiveModuleArea(game, area); + auto actor = game.newCreature(); + auto item = game.newItem(); + item->deserialize( + *runtimeItem("unowned_equip"), + SerializedIdentityContext::templateResource()); + auto action = game.newAction( + item, InventorySlots::body, true); + + action->execute(action, *actor, 0.0f); + + EXPECT_FALSE(actor->getEquippedItem(InventorySlots::body)); + EXPECT_EQ(0u, item->owner()); + EXPECT_FALSE(item->isEquipped()); + EXPECT_TRUE(item->isRuntimeLive()); + EXPECT_EQ(item, game.getObjectById(item->id())); +} + +TEST(RuntimeObjectOwnership, direct_nested_ownership_rejects_area_item) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto area = game.newArea(); + TestGameModule::setActiveModuleArea(game, area); + auto actor = game.newCreature(); + auto item = game.newItem(); + item->deserialize( + *runtimeItem("world_direct"), + SerializedIdentityContext::templateResource()); + area->add(item); + + EXPECT_FALSE(actor->equip(InventorySlots::body, item)); + EXPECT_THROW(actor->addItem(item), ValidationException); + + EXPECT_FALSE(actor->getEquippedItem(InventorySlots::body)); + EXPECT_TRUE(actor->items().empty()); + EXPECT_EQ(0u, item->owner()); + EXPECT_FALSE(item->isEquipped()); + EXPECT_NE( + area->objects().end(), + std::find(area->objects().begin(), area->objects().end(), item)); + ASSERT_EQ(1u, area->getObjectsByType(ObjectType::Item).size()); + EXPECT_EQ(item, area->getObjectsByType(ObjectType::Item).front()); +} + +TEST(RuntimeObjectOwnership, equip_action_moves_equipped_item_between_creatures) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto source = game.newCreature(); + auto receiver = game.newCreature(); + auto item = game.newItem(); + item->deserialize( + *runtimeItem("action_transfer"), + SerializedIdentityContext::templateResource()); + ASSERT_TRUE(source->equip(InventorySlots::body, item)); + auto action = game.newAction( + item, InventorySlots::body, true); + + action->execute(action, *receiver, 0.0f); + + EXPECT_FALSE(source->getEquippedItem(InventorySlots::body)); + EXPECT_EQ(item, receiver->getEquippedItem(InventorySlots::body)); + EXPECT_EQ(receiver->id(), item->owner()); + EXPECT_TRUE(item->isEquipped()); +} + +TEST(RuntimeObjectOwnership, rejected_replacement_preserves_original_graph) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto owner = game.newCreature(); + auto original = game.newItem(); + original->deserialize( + *runtimeItem("original"), + SerializedIdentityContext::templateResource()); + auto rejected = game.newItem(); + rejected->deserialize( + *runtimeItem("rejected"), + SerializedIdentityContext::templateResource()); + ASSERT_TRUE(owner->equip(InventorySlots::body, original)); + owner->addItem(rejected); + + EXPECT_FALSE(owner->replaceEquipment( + InventorySlots::body, rejected, *owner)); + + EXPECT_EQ(original, owner->getEquippedItem(InventorySlots::body)); + EXPECT_TRUE(original->isEquipped()); + EXPECT_EQ(owner->id(), original->owner()); + ASSERT_EQ(1u, owner->items().size()); + EXPECT_EQ(rejected, owner->items().front()); + EXPECT_EQ(owner->id(), rejected->owner()); +} + +TEST(RuntimeObjectOwnership, displaced_equipment_merges_and_is_finalized) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto owner = game.newCreature(); + auto displaced = game.newItem(); + displaced->deserialize( + *runtimeItem("stack"), + SerializedIdentityContext::templateResource()); + auto retained = game.newItem(); + retained->deserialize( + *runtimeItem("stack"), + SerializedIdentityContext::templateResource()); + auto replacement = game.newItem(); + replacement->deserialize( + *runtimeItem("replacement"), + SerializedIdentityContext::templateResource()); + ASSERT_TRUE(owner->equip(InventorySlots::body, displaced)); + owner->addItem(retained); + + ASSERT_TRUE(owner->replaceEquipment( + InventorySlots::body, replacement, *owner)); + + EXPECT_EQ(replacement, owner->getEquippedItem(InventorySlots::body)); + ASSERT_EQ(1u, owner->items().size()); + EXPECT_EQ(retained, owner->items().front()); + EXPECT_EQ(2, retained->stackSize()); + EXPECT_FALSE(displaced->isRuntimeLive()); + EXPECT_FALSE(game.getObjectById(displaced->id())); + EXPECT_EQ(0u, displaced->owner()); +} + +TEST(RuntimeObjectOwnership, repeated_equip_and_unequip_preserves_one_owner_edge) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto owner = game.newCreature(); + auto item = game.newItem(); + item->deserialize( + *runtimeItem("cycle"), + SerializedIdentityContext::templateResource()); + owner->addItem(item); + const size_t registrySize = TestGameModule::objectRegistrySize(game); + + for (int i = 0; i < 5; ++i) { + auto candidate = takeEquipmentCandidate(game, *owner, item); + ASSERT_EQ(item, candidate); + ASSERT_TRUE(owner->equip(InventorySlots::body, candidate)); + ASSERT_TRUE(owner->moveEquippedItemTo(candidate, *owner)); + } + + EXPECT_EQ(registrySize, TestGameModule::objectRegistrySize(game)); + ASSERT_EQ(1u, owner->items().size()); + EXPECT_EQ(item, owner->items().front()); + EXPECT_EQ(owner->id(), item->owner()); + EXPECT_FALSE(item->isEquipped()); +} + +TEST(RuntimeObjectOwnership, failed_blueprint_equip_does_not_leak_item) { + TestEngine engine; + engine.init(); + EXPECT_CALL( + engine.resourceModule().gffs(), + get("not_equipment", resource::ResType::Uti)) + .WillOnce(Return(nullptr)); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto owner = game.newCreature(); + const size_t registrySize = TestGameModule::objectRegistrySize(game); + + EXPECT_FALSE(owner->equip("not_equipment")); + + EXPECT_EQ(registrySize, TestGameModule::objectRegistrySize(game)); +} + +TEST(RuntimeObjectOwnership, template_and_instance_item_graph_overlay_is_exact) { + TestEngine engine; + engine.init(); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeBaseItems())); + EXPECT_CALL(engine.resourceModule().twoDas(), get("appearance")) + .Times(AnyNumber()) + .WillRepeatedly(Return(runtimeAppearances())); + EXPECT_CALL(engine.resourceModule().textures(), get(_, _)).Times(AnyNumber()); + EXPECT_CALL(engine.resourceModule().models(), get(_)).Times(AnyNumber()); + EXPECT_CALL( + static_cast(engine.services().game.portraits), + getTextureByAppearance(_)) + .Times(AnyNumber()); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto creature = game.newCreature(); + auto templated = resource::Gff::Builder() + .field(resource::Gff::Field::newList( + "ItemList", {runtimeItem("template_item")})) + .build(); + creature->deserialize( + *templated, SerializedIdentityContext::templateResource("template")); + auto templateItem = creature->items().front(); + + auto noOverride = resource::Gff::Builder() + .field(resource::Gff::Field::newDword("ObjectId", 860)) + .build(); + creature->deserialize( + *noOverride, SerializedIdentityContext::moduleGraph("overlay")); + ASSERT_EQ(1u, creature->items().size()); + EXPECT_EQ(templateItem, creature->items().front()); + + auto overrideRecord = resource::Gff::Builder() + .field(resource::Gff::Field::newDword("ObjectId", 860)) + .field(resource::Gff::Field::newList( + "ItemList", {runtimeItem("instance_item", 861)})) + .build(); + creature->deserialize( + *overrideRecord, SerializedIdentityContext::moduleGraph("overlay")); + + ASSERT_EQ(1u, creature->items().size()); + EXPECT_EQ("instance_item", creature->items().front()->tag()); + EXPECT_FALSE(templateItem->isRuntimeLive()); + EXPECT_FALSE(game.getObjectById(templateItem->id())); +} + +TEST(RuntimeObjectPresentation, preview_objects_never_enter_gameplay_registry) { + TestEngine engine; + engine.init(); + StubConsole console; + Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); + auto gameplayItem = game.newItem(); + gameplayItem->setOwner(77); + const size_t registrySize = TestGameModule::objectRegistrySize(game); + for (int i = 0; i < 5; ++i) { + auto preview = game.newPresentationCreature("preview"); + auto previewItem = game.newPresentationItem(); + previewItem->clone(*gameplayItem); + + EXPECT_TRUE(preview->isPresentationOnly()); + EXPECT_TRUE(previewItem->isPresentationOnly()); + EXPECT_FALSE(preview->isRuntimeLive()); + EXPECT_FALSE(game.getObjectById(preview->id())); + EXPECT_FALSE(game.getObjectById(previewItem->id())); + } + EXPECT_EQ(registrySize, TestGameModule::objectRegistrySize(game)); + EXPECT_EQ(77u, gameplayItem->owner()); +} + +TEST(AreaRuntimeRetirement, candidate_cleanup_handles_partial_party_placement_idempotently) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + Room room("partial", glm::vec3(0.0f), nullptr, nullptr, nullptr); + ON_CALL(sceneGraph, testElevation(_, _)) + .WillByDefault(Invoke([&room]( + const glm::vec3 &position, + scene::Collision &collision) { + collision.intersection = position; + collision.user = &room; + return true; + })); + StubConsole console; + Game game(resource::GameID::TSL, "", engine.options(), engine.services(), console); + auto area = game.newArea(); + TestGameModule::setActiveModuleArea(game, area); + + auto pc = game.newCreature(); + game.party().addMember(kNpcPlayer, pc); + game.party().setPlayer(pc); + game.party().setActualPlayer(pc); + auto companion = game.newCreature(); + ASSERT_TRUE(game.party().addAvailableMember(0, companion)); + ASSERT_TRUE(game.party().addMember(0, companion)); + auto puppet = game.newCreature(); + ASSERT_TRUE(game.party().addAvailablePuppet(0, puppet)); + ASSERT_TRUE(game.party().addPuppet(0, puppet)); + + auto trigger = game.newTrigger(); + area->add(trigger); + area->add(pc); + area->add(puppet); + trigger->addTenant(pc); + trigger->addTenant(puppet); + TestGameModule::setAreaRuntimePath(*pc, area->pathfinder()); + TestGameModule::setAreaRuntimePath(*puppet, area->pathfinder()); + ASSERT_EQ(&room, pc->room()); + ASSERT_EQ(&room, puppet->room()); + ASSERT_EQ(nullptr, companion->room()); + + game.retireActiveModuleRuntime(); + + EXPECT_FALSE(game.module()); + EXPECT_EQ(nullptr, pc->room()); + EXPECT_EQ(nullptr, puppet->room()); + EXPECT_EQ(nullptr, companion->room()); + EXPECT_FALSE(room.tenants().count(pc.get())); + EXPECT_FALSE(room.tenants().count(puppet.get())); + EXPECT_FALSE(trigger->isTenant(pc)); + EXPECT_FALSE(trigger->isTenant(puppet)); + EXPECT_FALSE(TestGameModule::hasAreaRuntimePath(*pc)); + EXPECT_FALSE(TestGameModule::hasAreaRuntimePath(*puppet)); + EXPECT_EQ(pc, game.party().actualPlayer()); + EXPECT_EQ(companion, game.party().rosterCreature({RosterKind::Npc, 0})); + EXPECT_EQ(puppet, game.party().rosterCreature({RosterKind::Puppet, 0})); + EXPECT_EQ(pc, game.getObjectById(pc->id())); + EXPECT_EQ(companion, game.getObjectById(companion->id())); + EXPECT_EQ(puppet, game.getObjectById(puppet->id())); + + // Re-entering both cleanup boundaries after partial retirement is safe. + game.retireActiveModuleRuntime(); + EXPECT_CALL(engine.audioModule().mixer(), stopAll()).Times(1); + EXPECT_CALL(sceneGraph, clear()).Times(AnyNumber()); + game.retireRuntimeSession(); +} + +TEST(AreaRuntimeRetirement, retire_party_includes_inactive_roster_and_puppets) { + for (auto gameId : {resource::GameID::KotOR, resource::GameID::TSL}) { + TestEngine engine; + engine.init(); + NiceMock sceneGraph; + configureRuntimeMocks(engine, sceneGraph); + StubConsole console; + Game game(gameId, "", engine.options(), engine.services(), console); + auto area = game.newArea(); + + auto pc = game.newCreature(); + game.party().addMember(kNpcPlayer, pc); + game.party().setPlayer(pc); + game.party().setActualPlayer(pc); + area->add(pc); + + auto inactive = game.newCreature(); + game.party().addAvailableMember(0, inactive); + area->add(inactive); + + std::shared_ptr puppet; + if (gameId == resource::GameID::TSL) { + puppet = game.newCreature(); + game.party().addAvailablePuppet(0, puppet); + area->add(puppet); + } + + area->retirePartyAreaRuntime(); + + const auto &creatures = area->getObjectsByType(ObjectType::Creature); + EXPECT_EQ(creatures.end(), std::find(creatures.begin(), creatures.end(), pc)); + EXPECT_EQ(creatures.end(), std::find(creatures.begin(), creatures.end(), inactive)); + if (puppet) { + EXPECT_EQ(creatures.end(), std::find(creatures.begin(), creatures.end(), puppet)); + EXPECT_EQ(puppet, game.party().rosterCreature({RosterKind::Puppet, 0})); + } + EXPECT_EQ(inactive, game.party().rosterCreature({RosterKind::Npc, 0})); + } +} + TEST(PartyTransferSpawn, initial_save_restore_places_the_party_without_spawning_it) { for (auto gameId : {resource::GameID::KotOR, resource::GameID::TSL}) { PartyTransferHarness harness(gameId); @@ -836,6 +2711,7 @@ TEST(PartyTransferSpawn, session_replacement_does_not_leak_the_old_party) { harness.game->party().setActualPlayer(player); auto companion = harness.game->newCreature(); companion->setOnSpawn("npc_spawn"); + harness.game->party().addAvailableMember(0, companion); harness.game->party().addMember(0, companion); EXPECT_NE(retiredCompanion.get(), companion.get()); diff --git a/test/game/savedruntime.cpp b/test/game/savedruntime.cpp index 0b1979fe9..056271d44 100644 --- a/test/game/savedruntime.cpp +++ b/test/game/savedruntime.cpp @@ -10,6 +10,8 @@ #include #include +#include + #include "../fixtures/engine.h" #include "../fixtures/game.h" @@ -34,6 +36,10 @@ using namespace testing; namespace { +SerializedIdentityContext savedRuntimeIdentityContext() { + return SerializedIdentityContext::moduleGraph("test-module"); +} + std::shared_ptr valueParameter(uint32_t type, Gff::Field value) { return Gff::Builder() .type(type) @@ -180,10 +186,21 @@ std::shared_ptr minimalEffect() { .field(Gff::Field::newWord("Type", 68)) .field(Gff::Field::newWord("SubType", 1)) .field(Gff::Field::newFloat("Duration", 2.0f)) - .field(Gff::Field::newDword("CreatorId", 3)) + .field(Gff::Field::newDword( + "CreatorId", kSavedEffectInvalidObjectId)) .build(); } +void registerModuleIdentity( + Game &game, + uint32_t savedId, + const std::shared_ptr &object) { + game.registerSavedObjectIdentity( + savedId, + object, + SerializedIdentityContext::moduleGraph("test-module")); +} + TEST(SavedAction, should_preserve_retail_fields_parameter_types_and_order) { std::vector> parameters { valueParameter(1, Gff::Field::newInt("Value", -11)), @@ -199,7 +216,8 @@ TEST(SavedAction, should_preserve_retail_fields_parameter_types_and_order) { {action(30, 0), action(37, 0xffff, std::move(parameters)), action(1, 0xfffe)})) .build(); - SavedActionQueue queue = SavedActionQueue::fromGff(*root); + SavedActionQueue queue = SavedActionQueue::fromGff( + *root, savedRuntimeIdentityContext()); ASSERT_EQ(queue.actions.size(), 3); EXPECT_EQ(queue.actions[0].actionId, 30); @@ -224,8 +242,10 @@ TEST(SavedAction, should_convert_only_a_proven_supported_reone_action) { StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); auto wait = SavedActionRecord::fromGff( - *action(30, 7, {valueParameter(2, Gff::Field::newFloat("Value", 1.5f))})); - auto unknown = SavedActionRecord::fromGff(*action(0xdead, 8)); + *action(30, 7, {valueParameter(2, Gff::Field::newFloat("Value", 1.5f))}), + savedRuntimeIdentityContext()); + auto unknown = SavedActionRecord::fromGff( + *action(0xdead, 8), savedRuntimeIdentityContext()); auto runtime = wait.toRuntimeAction(game); ASSERT_TRUE(runtime); @@ -254,7 +274,8 @@ TEST(SavedAction, follow_leader_uses_exact_parameterless_retail_shape_in_both_ti EXPECT_TRUE(exported->parameters.empty()); auto encoded = action(exported->actionId, exported->groupActionId); - auto imported = SavedActionRecord::fromGff(*encoded); + auto imported = SavedActionRecord::fromGff( + *encoded, savedRuntimeIdentityContext()); EXPECT_EQ(imported.executionSupport(), SavedExecutionSupport::Executable); auto restored = std::dynamic_pointer_cast( imported.toRuntimeAction(game)); @@ -275,12 +296,14 @@ TEST(SavedAction, follow_leader_rejects_any_parameter_shape) { Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); auto wrongCount = SavedActionRecord::fromGff( - *action(61, 7, {valueParameter(1, Gff::Field::newInt("Value", 0))})); + *action(61, 7, {valueParameter(1, Gff::Field::newInt("Value", 0))}), + savedRuntimeIdentityContext()); EXPECT_EQ(wrongCount.executionSupport(), SavedExecutionSupport::RepresentableButUnsupported); EXPECT_FALSE(wrongCount.toRuntimeAction(game)); - auto hiddenParameter = SavedActionRecord::fromGff(*action(61, 7)); + auto hiddenParameter = SavedActionRecord::fromGff( + *action(61, 7), savedRuntimeIdentityContext()); hiddenParameter.parameters.push_back(SavedActionParameter { static_cast(SavedActionParameterType::Integer), int32_t {0}}); EXPECT_EQ(hiddenParameter.executionSupport(), @@ -294,7 +317,8 @@ TEST(SavedAction, restored_follow_leader_resolves_the_leader_only_at_execution) Game game(resource::GameID::KotOR, "", engine.options(), engine.services(), console); ASSERT_TRUE(game.party().isEmpty()); - auto record = SavedActionRecord::fromGff(*action(61, 9)); + auto record = SavedActionRecord::fromGff( + *action(61, 9), savedRuntimeIdentityContext()); auto restored = std::dynamic_pointer_cast( record.toRuntimeAction(game)); ASSERT_TRUE(restored); @@ -353,7 +377,9 @@ TEST(SavedAction, attack_object_imports_bound_target_and_preserves_group) { StubConsole console; Game game(GameID::TSL, "", engine.options(), engine.services(), console); auto target = game.newCreature(); - auto record = SavedActionRecord::fromGff(*basicAttackAction(target->id(), 41)); + registerModuleIdentity(game, target->id(), target); + auto record = SavedActionRecord::fromGff( + *basicAttackAction(target->id(), 41), savedRuntimeIdentityContext()); ASSERT_EQ(record.executionSupport(), SavedExecutionSupport::Executable); ASSERT_TRUE(record.bindObjectReferences(game)); @@ -372,12 +398,14 @@ TEST(SavedAction, attack_object_rejects_malformed_special_or_missing_targets) { TestEngine &engine = testEngine(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); - auto record = SavedActionRecord::fromGff(*basicAttackAction(123456)); + auto record = SavedActionRecord::fromGff( + *basicAttackAction(123456), savedRuntimeIdentityContext()); EXPECT_FALSE(record.bindObjectReferences(game)); EXPECT_FALSE(record.toRuntimeAction(game)); auto target = game.newCreature(); - record = SavedActionRecord::fromGff(*basicAttackAction(target->id())); + record = SavedActionRecord::fromGff( + *basicAttackAction(target->id()), savedRuntimeIdentityContext()); record.parameters[6].payload = int32_t {1703936}; EXPECT_EQ(record.executionSupport(), SavedExecutionSupport::RepresentableButUnsupported); @@ -410,6 +438,7 @@ TEST(SavedRuntimePublication, attack_object_is_installed_inertly_ahead_of_later_ StubConsole console; Game game(GameID::TSL, "", engine.options(), engine.services(), console); auto target = game.newCreature(); + registerModuleIdentity(game, target->id(), target); auto saved = Gff::Builder() .field(Gff::Field::newList( "ActionList", @@ -421,7 +450,7 @@ TEST(SavedRuntimePublication, attack_object_is_installed_inertly_ahead_of_later_ .build(); auto actor = game.newCreature(); - actor->deserializeRuntimeState(*saved); + actor->deserializeRuntimeState(*saved, savedRuntimeIdentityContext()); EXPECT_TRUE(actor->actions().empty()); actor->bindSavedRuntimeState(); EXPECT_TRUE(actor->actions().empty()); @@ -448,7 +477,8 @@ TEST(SavedAction, play_animation_rejects_malformed_parameter_shapes) { valueParameter(1, Gff::Field::newInt("Value", 1)), valueParameter(1, Gff::Field::newInt("Value", 0)), }); - auto record = SavedActionRecord::fromGff(*valid); + auto record = SavedActionRecord::fromGff( + *valid, savedRuntimeIdentityContext()); EXPECT_EQ(record.executionSupport(), SavedExecutionSupport::Executable); EXPECT_TRUE(record.toRuntimeAction(game)); @@ -474,6 +504,7 @@ TEST(SavedAction, move_to_object_uses_retail_action_17_shape_and_bound_target) { StubConsole console; Game game(GameID::TSL, "", engine.options(), engine.services(), console); auto target = game.newCreature(); + registerModuleIdentity(game, target->id(), target); auto saved = action(17, 13, { valueParameter(3, Gff::Field::newDword("Value", target->id())), valueParameter(1, Gff::Field::newInt("Value", 0)), @@ -481,7 +512,8 @@ TEST(SavedAction, move_to_object_uses_retail_action_17_shape_and_bound_target) { valueParameter(2, Gff::Field::newFloat("Value", 0.5f)), valueParameter(1, Gff::Field::newInt("Value", 1)), }); - auto record = SavedActionRecord::fromGff(*saved); + auto record = SavedActionRecord::fromGff( + *saved, savedRuntimeIdentityContext()); EXPECT_EQ(record.executionSupport(), SavedExecutionSupport::Executable); EXPECT_TRUE(record.bindObjectReferences(game)); @@ -520,7 +552,8 @@ TEST(SavedAction, move_to_object_rejects_malformed_or_missing_target) { valueParameter(2, Gff::Field::newFloat("Value", 1.0f)), valueParameter(1, Gff::Field::newInt("Value", 1)), }); - auto record = SavedActionRecord::fromGff(*saved); + auto record = SavedActionRecord::fromGff( + *saved, savedRuntimeIdentityContext()); EXPECT_FALSE(record.bindObjectReferences(game)); EXPECT_FALSE(record.toRuntimeAction(game)); @@ -543,6 +576,8 @@ TEST(SavedAction, forced_move_to_object_uses_retail_action_1_and_preserves_timin Game game(GameID::TSL, "", engine.options(), engine.services(), console); auto area = game.newArea(); auto target = game.newCreature(); + registerModuleIdentity(game, area->id(), area); + registerModuleIdentity(game, target->id(), target); auto saved = action(1, 13, { valueParameter(2, Gff::Field::newFloat("Value", 46.0f)), valueParameter(2, Gff::Field::newFloat("Value", 17.0f)), @@ -558,7 +593,8 @@ TEST(SavedAction, forced_move_to_object_uses_retail_action_1_and_preserves_timin valueParameter(1, Gff::Field::newInt("Value", 0)), valueParameter(1, Gff::Field::newInt("Value", 0)), }); - auto record = SavedActionRecord::fromGff(*saved); + auto record = SavedActionRecord::fromGff( + *saved, savedRuntimeIdentityContext()); ASSERT_EQ(record.executionSupport(), SavedExecutionSupport::Executable); ASSERT_TRUE(record.bindObjectReferences(game)); auto runtime = std::dynamic_pointer_cast(record.toRuntimeAction(game)); @@ -629,8 +665,10 @@ TEST(SavedAction, move_to_location_imports_ordinary_pending_and_active_forced_ti StubConsole console; Game game(GameID::TSL, "", engine.options(), engine.services(), console); auto area = game.newArea(); + registerModuleIdentity(game, area->id(), area); - auto ordinaryRecord = SavedActionRecord::fromGff(*moveToPointAction(area->id())); + auto ordinaryRecord = SavedActionRecord::fromGff( + *moveToPointAction(area->id()), savedRuntimeIdentityContext()); ASSERT_TRUE(ordinaryRecord.bindObjectReferences(game)); auto ordinary = std::dynamic_pointer_cast( ordinaryRecord.toRuntimeAction(game)); @@ -640,7 +678,8 @@ TEST(SavedAction, move_to_location_imports_ordinary_pending_and_active_forced_ti EXPECT_EQ(ordinary->destination()->position(), glm::vec3(12.0f, 34.0f, 5.0f)); auto pendingRecord = SavedActionRecord::fromGff( - *moveToPointAction(area->id(), glm::vec3(4.0f), 5, 30.0f, 0, 0, 22)); + *moveToPointAction(area->id(), glm::vec3(4.0f), 5, 30.0f, 0, 0, 22), + savedRuntimeIdentityContext()); ASSERT_TRUE(pendingRecord.bindObjectReferences(game)); auto pending = std::dynamic_pointer_cast( pendingRecord.toRuntimeAction(game)); @@ -651,7 +690,8 @@ TEST(SavedAction, move_to_location_imports_ordinary_pending_and_active_forced_ti EXPECT_EQ(pending->originalSavedAction()->groupActionId, 22); auto activeRecord = SavedActionRecord::fromGff( - *moveToPointAction(area->id(), glm::vec3(8.0f), 1, 0.0f, 7, 12345)); + *moveToPointAction(area->id(), glm::vec3(8.0f), 1, 0.0f, 7, 12345), + savedRuntimeIdentityContext()); ASSERT_TRUE(activeRecord.bindObjectReferences(game)); auto active = std::dynamic_pointer_cast( activeRecord.toRuntimeAction(game)); @@ -674,7 +714,9 @@ TEST(SavedAction, move_to_location_rejects_malformed_semantics_and_invalid_area) StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); auto area = game.newArea(); - auto record = SavedActionRecord::fromGff(*moveToPointAction(area->id())); + registerModuleIdentity(game, area->id(), area); + auto record = SavedActionRecord::fromGff( + *moveToPointAction(area->id()), savedRuntimeIdentityContext()); ASSERT_TRUE(record.bindObjectReferences(game)); record.parameters[6].payload = 1.0f; @@ -689,11 +731,13 @@ TEST(SavedAction, move_to_location_rejects_malformed_semantics_and_invalid_area) record.parameters[0].payload = std::numeric_limits::quiet_NaN(); EXPECT_FALSE(record.toRuntimeAction(game)); - auto missingArea = SavedActionRecord::fromGff(*moveToPointAction(123456)); + auto missingArea = SavedActionRecord::fromGff( + *moveToPointAction(123456), savedRuntimeIdentityContext()); EXPECT_FALSE(missingArea.bindObjectReferences(game)); EXPECT_FALSE(missingArea.toRuntimeAction(game)); auto invalidArea = SavedActionRecord::fromGff( - *moveToPointAction(kSavedRuntimeInvalidObjectId)); + *moveToPointAction(kSavedRuntimeInvalidObjectId), + savedRuntimeIdentityContext()); ASSERT_TRUE(invalidArea.bindObjectReferences(game)); EXPECT_FALSE(invalidArea.toRuntimeAction(game)); record.declaredParameterCount = 12; @@ -705,6 +749,7 @@ TEST(SavedRuntimePublication, move_to_location_is_inert_and_blocks_later_action) StubConsole console; Game game(GameID::TSL, "", engine.options(), engine.services(), console); auto area = game.newArea(); + registerModuleIdentity(game, area->id(), area); auto saved = Gff::Builder() .field(Gff::Field::newList( "ActionList", @@ -714,7 +759,7 @@ TEST(SavedRuntimePublication, move_to_location_is_inert_and_blocks_later_action) .build(); auto actor = game.newCreature(); - actor->deserializeRuntimeState(*saved); + actor->deserializeRuntimeState(*saved, savedRuntimeIdentityContext()); EXPECT_TRUE(actor->actions().empty()); actor->bindSavedRuntimeState(); EXPECT_TRUE(actor->actions().empty()); @@ -733,6 +778,8 @@ TEST(SavedAction, active_forced_move_preserves_absolute_world_time) { Game game(GameID::TSL, "", engine.options(), engine.services(), console); auto area = game.newArea(); auto target = game.newCreature(); + registerModuleIdentity(game, area->id(), area); + registerModuleIdentity(game, target->id(), target); auto saved = action(1, 27, { valueParameter(2, Gff::Field::newFloat("Value", 1.0f)), valueParameter(2, Gff::Field::newFloat("Value", 2.0f)), valueParameter(2, Gff::Field::newFloat("Value", 3.0f)), valueParameter(3, Gff::Field::newDword("Value", area->id())), @@ -742,7 +789,8 @@ TEST(SavedAction, active_forced_move_preserves_absolute_world_time) { valueParameter(2, Gff::Field::newFloat("Value", 0.0f)), valueParameter(1, Gff::Field::newInt("Value", 4)), valueParameter(1, Gff::Field::newInt("Value", 12345)), }); - auto record = SavedActionRecord::fromGff(*saved); + auto record = SavedActionRecord::fromGff( + *saved, savedRuntimeIdentityContext()); ASSERT_TRUE(record.bindObjectReferences(game)); auto runtime = std::dynamic_pointer_cast(record.toRuntimeAction(game)); ASSERT_TRUE(runtime); @@ -798,12 +846,14 @@ TEST(SavedAction, forced_move_accepts_an_unnormalized_legacy_time_of_day) { StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); auto area = game.newArea(); + registerModuleIdentity(game, area->id(), area); constexpr uint32_t kLegacyTimeOfDay = 24u * 60u * 60u * 1000u - 1u; ASSERT_GT(kLegacyTimeOfDay, game.millisecondsPerWorldDay()); auto record = SavedActionRecord::fromGff( *moveToPointAction(area->id(), glm::vec3(8.0f), 1, 0.0f, 2, - static_cast(kLegacyTimeOfDay))); + static_cast(kLegacyTimeOfDay)), + savedRuntimeIdentityContext()); ASSERT_TRUE(record.bindObjectReferences(game)); auto restored = std::dynamic_pointer_cast( record.toRuntimeAction(game)); @@ -831,20 +881,25 @@ TEST(SavedRuntimePublication, should_compare_event_due_times_on_the_absolute_clo ON_CALL(engine.sceneModule().graphs(), get(_)).WillByDefault(ReturnRef(sceneGraph)); auto ifo = Gff::Builder() - .field(Gff::Field::newDword("Mod_CalendarDay", 4)) - .field(Gff::Field::newDword("Mod_TimeOfDay", 0)) + .field(Gff::Field::newDword("Mod_PauseDay", 4)) + .field(Gff::Field::newDword("Mod_PauseTime", 0)) .field(Gff::Field::newDword("Mod_MinPerHour", 5)) .build(); - game.prepareSavedRuntimeNamespace(*ifo); + game.prepareSavedRuntimeNamespace( + *ifo, SerializedIdentityContext::moduleGraph("test-module")); const uint32_t millisecondsPerDay = game.millisecondsPerWorldDay(); auto module = game.newModule(); + auto caller = game.newCreature(); + registerModuleIdentity(game, 2, module); + registerModuleIdentity(game, 3, caller); auto queue = Gff::Builder() .field(Gff::Field::newList( "EventQueue", {event(5, 4, 2u * millisecondsPerDay, minimalEffect())})) .build(); - module->deserializeSavedEventQueue(*queue); + module->deserializeSavedEventQueue( + *queue, savedRuntimeIdentityContext()); module->bindSavedEventQueue(); module->publishSavedEventQueue(); ASSERT_EQ(module->pendingSavedEventCount(), 1); @@ -951,7 +1006,8 @@ TEST(SavedAction, start_conversation_rejects_malformed_or_unbound_records) { valueParameter(4, Gff::Field::newCExoString("Value", "dialog")), valueParameter(1, Gff::Field::newInt("Value", 0)), }); - auto record = SavedActionRecord::fromGff(*saved); + auto record = SavedActionRecord::fromGff( + *saved, savedRuntimeIdentityContext()); EXPECT_FALSE(record.bindObjectReferences(game)); EXPECT_FALSE(record.toRuntimeAction(game)); record.declaredParameterCount = 2; @@ -966,6 +1022,7 @@ TEST(SavedRuntimePublication, move_to_object_waits_for_publication_and_preserves StubConsole console; Game game(GameID::TSL, "", engine.options(), engine.services(), console); auto target = game.newCreature(); + registerModuleIdentity(game, target->id(), target); auto saved = Gff::Builder() .field(Gff::Field::newList( "ActionList", @@ -981,7 +1038,7 @@ TEST(SavedRuntimePublication, move_to_object_waits_for_publication_and_preserves })})) .build(); auto actor = game.newCreature(); - actor->deserializeRuntimeState(*saved); + actor->deserializeRuntimeState(*saved, savedRuntimeIdentityContext()); EXPECT_TRUE(actor->actions().empty()); actor->bindSavedRuntimeState(); @@ -1017,7 +1074,7 @@ TEST(SavedRuntimePublication, should_separate_parse_bind_and_idempotent_publicat .build(); auto object = game.newCreature(); - object->deserializeRuntimeState(*saved); + object->deserializeRuntimeState(*saved, savedRuntimeIdentityContext()); ASSERT_EQ(object->savedEffects().size(), 1); ASSERT_EQ(object->savedActionQueue().actions.size(), 2); @@ -1047,6 +1104,66 @@ TEST(SavedRuntimePublication, should_separate_parse_bind_and_idempotent_publicat EXPECT_EQ(object->actions().size(), 1); } +TEST(SavedRuntimePublication, detached_state_never_binds_through_module_graph) { + TestEngine &engine = testEngine(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + auto moduleTarget = game.newCreature(); + registerModuleIdentity(game, 77u, moduleTarget); + auto effect = minimalEffect(); + replaceSaveField( + *effect, Gff::Field::newDword("CreatorId", 77u)); + auto record = Gff::Builder() + .field(Gff::Field::newList("EffectList", {effect})) + .field(Gff::Field::newList( + "ActionList", {basicAttackAction(77u)})) + .build(); + auto detached = game.newCreature(); + detached->deserializeRuntimeState( + *record, + SerializedIdentityContext::detachedRecord("availnpc0.utc")); + + detached->bindSavedRuntimeState(); + detached->publishSavedRuntimeState(); + + ASSERT_EQ(detached->savedEffects().size(), 1u); + EXPECT_FALSE(detached->savedEffects().front().boundCreator()); + EXPECT_TRUE(detached->effects().empty()); + EXPECT_TRUE(detached->actions().empty()); + EXPECT_NE(game.getObjectBySavedId(77u), nullptr); +} + +TEST(SavedRuntimePublication, detached_state_binds_only_inside_exact_record) { + TestEngine &engine = testEngine(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + const auto context = + SerializedIdentityContext::detachedRecord("availnpc0.utc"); + auto localTarget = game.newCreature(); + localTarget->assignSerializedObjectIdentity({context, 77u}); + auto effect = minimalEffect(); + replaceSaveField( + *effect, Gff::Field::newDword("CreatorId", 77u)); + auto record = Gff::Builder() + .field(Gff::Field::newList("EffectList", {effect})) + .field(Gff::Field::newList( + "ActionList", {basicAttackAction(77u)})) + .build(); + auto detached = game.newCreature(); + detached->deserializeRuntimeState(*record, context); + + detached->bindSavedRuntimeState(); + detached->publishSavedRuntimeState(); + + ASSERT_EQ(detached->effects().size(), 1u); + EXPECT_EQ(detached->effects().front().boundCreator(), localTarget); + ASSERT_EQ(detached->actions().size(), 1u); + auto attack = std::dynamic_pointer_cast( + detached->actions().front()); + ASSERT_TRUE(attack); + EXPECT_EQ(attack->target(), localTarget); +} + TEST(SavedRuntimePublication, should_publish_supported_events_without_dispatching_them) { TestEngine &engine = testEngine(); @@ -1056,12 +1173,16 @@ TEST(SavedRuntimePublication, should_publish_supported_events_without_dispatchin ON_CALL(engine.sceneModule().graphs(), get(_)).WillByDefault(ReturnRef(sceneGraph)); auto ifo = Gff::Builder() - .field(Gff::Field::newDword("Mod_CalendarDay", 4)) - .field(Gff::Field::newDword("Mod_TimeOfDay", 100)) + .field(Gff::Field::newDword("Mod_PauseDay", 4)) + .field(Gff::Field::newDword("Mod_PauseTime", 100)) .field(Gff::Field::newDword("Mod_MinPerHour", 5)) .build(); - game.prepareSavedRuntimeNamespace(*ifo); + game.prepareSavedRuntimeNamespace( + *ifo, SerializedIdentityContext::moduleGraph("test-module")); auto module = game.newModule(); + auto caller = game.newCreature(); + registerModuleIdentity(game, 2, module); + registerModuleIdentity(game, 3, caller); auto queue = Gff::Builder() .field(Gff::Field::newList( @@ -1070,7 +1191,8 @@ TEST(SavedRuntimePublication, should_publish_supported_events_without_dispatchin event(11, 4, 101), event(18, 4, 102)})) .build(); - module->deserializeSavedEventQueue(*queue); + module->deserializeSavedEventQueue( + *queue, savedRuntimeIdentityContext()); module->bindSavedEventQueue(); module->publishSavedEventQueue(); @@ -1105,7 +1227,8 @@ TEST(SavedRuntimePublication, should_publish_supported_events_without_dispatchin TEST(ScriptSituation, should_preserve_the_retail_snapshot_without_claiming_resume) { - SerializedScriptSituation situation = SerializedScriptSituation::fromGff(*savedSituation()); + SerializedScriptSituation situation = SerializedScriptSituation::fromGff( + *savedSituation(), savedRuntimeIdentityContext()); EXPECT_EQ(situation.codeSize, 4); EXPECT_EQ(situation.code, (ByteBuffer {1, 2, 3, 4})); @@ -1132,29 +1255,103 @@ TEST(SavedObjectReference, should_bind_only_after_B_exists_and_never_rebind_A_in Game game(GameID::KotOR, "", engine.options(), engine.services(), console); NiceMock sceneGraph; ON_CALL(engine.sceneModule().graphs(), get(_)).WillByDefault(ReturnRef(sceneGraph)); + TwoDA::Builder appearances; + appearances.columns({"modeltype", "walkdist", "rundist", "footsteptype", "envmap", "race", "racetex"}); + appearances.row({"S", "1", "1", "-1", "", "", ""}); + ON_CALL(engine.resourceModule().twoDas(), get("appearance")) + .WillByDefault(Return(std::shared_ptr(appearances.build()))); + + auto savedCreature = Gff::Builder() + .field(Gff::Field::newDword("ObjectId", 2)) + .build(); + auto git = Gff::Builder() + .field(Gff::Field::newList("Creature List", {savedCreature})) + .build(); + const auto identityContext = + SerializedIdentityContext::moduleGraph("test-module"); + game.reserveSavedObjectIds( + *git, identityContext, SerializedGraphRoot::AreaGit); - SavedObjectReference reference(2); + auto reference = SavedObjectReference::fromSerializedId( + 2, identityContext); EXPECT_FALSE(game.bindSavedObjectReference(reference)); - auto sessionA = game.newCreature(); - ASSERT_EQ(sessionA->id(), 2); + auto sessionA = game.newCreature(*savedCreature, identityContext); + ASSERT_NE(sessionA->id(), 2); ASSERT_TRUE(game.bindSavedObjectReference(reference)); EXPECT_EQ(reference.boundObject(), sessionA); game.retireRuntimeSession(); - auto sessionB = game.newCreature(); - ASSERT_EQ(sessionB->id(), 2); + game.reserveSavedObjectIds( + *git, identityContext, SerializedGraphRoot::AreaGit); + auto sessionB = game.newCreature(*savedCreature, identityContext); + ASSERT_NE(sessionB->id(), 2); EXPECT_FALSE(game.bindSavedObjectReference(reference)); EXPECT_FALSE(reference.boundObject()); EXPECT_NE(sessionA, sessionB); - SavedObjectReference parsedForB(2); + auto parsedForB = SavedObjectReference::fromSerializedId( + 2, identityContext); EXPECT_TRUE(game.bindSavedObjectReference(parsedForB)); EXPECT_EQ(parsedForB.boundObject(), sessionB); - SavedObjectReference invalid(kSavedRuntimeInvalidObjectId); + auto invalid = SavedObjectReference::fromSerializedId( + kSavedRuntimeInvalidObjectId, identityContext); EXPECT_FALSE(game.bindSavedObjectReference(invalid)); } +TEST(SavedObjectReference, serialized_identity_dies_with_its_module_graph) { + TestEngine &engine = testEngine(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + NiceMock sceneGraph; + ON_CALL(engine.sceneModule().graphs(), get(_)).WillByDefault(ReturnRef(sceneGraph)); + TwoDA::Builder appearances; + appearances.columns({"modeltype", "walkdist", "rundist", "footsteptype", "envmap", "race", "racetex"}); + appearances.row({"S", "1", "1", "-1", "", "", ""}); + ON_CALL(engine.resourceModule().twoDas(), get("appearance")) + .WillByDefault(Return(std::shared_ptr(appearances.build()))); + + const auto identityContext = + SerializedIdentityContext::moduleGraph("shared-module-name"); + auto savedCreature = Gff::Builder() + .field(Gff::Field::newDword("ObjectId", 2)) + .build(); + auto source = game.newCreature(*savedCreature, identityContext); + auto reference = SavedObjectReference::fromSerializedId( + 2, identityContext); + ASSERT_TRUE(game.bindSavedObjectReference(reference)); + ASSERT_EQ(reference.boundObject(), source); + + game.retireActiveModuleRuntime(); + auto destination = game.newCreature(*savedCreature, identityContext); + ASSERT_NE(source, destination); + EXPECT_FALSE(game.bindSavedObjectReference(reference)); + EXPECT_FALSE(reference.boundObject()); + + auto destinationReference = SavedObjectReference::fromSerializedId( + 2, identityContext); + EXPECT_TRUE(game.bindSavedObjectReference(destinationReference)); + EXPECT_EQ(destinationReference.boundObject(), destination); +} + +TEST(SavedObjectReference, runtime_identity_can_survive_an_ordinary_module_transition) { + TestEngine &engine = testEngine(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + + auto player = game.newCreature(); + game.party().addMember(kNpcPlayer, player); + game.party().setPlayer(player); + game.party().setActualPlayer(player); + auto reference = SavedObjectReference::fromRuntimeId(player->id()); + ASSERT_TRUE(game.bindSavedObjectReference(reference)); + + game.retireActiveModuleRuntime(); + + EXPECT_TRUE(game.bindSavedObjectReference(reference)); + EXPECT_EQ(reference.boundObject(), player); +} + TEST(SavedEventQueue, should_preserve_K1_and_K2_records_absolute_time_payload_and_order) { auto root = Gff::Builder() .field(Gff::Field::newList( @@ -1166,7 +1363,8 @@ TEST(SavedEventQueue, should_preserve_K1_and_K2_records_absolute_time_payload_an event(99, 4, 104, Gff::Builder().field(Gff::Field::newInt("Value", 7)).build())})) .build(); - SavedEventQueue queue = SavedEventQueue::fromGff(*root); + SavedEventQueue queue = SavedEventQueue::fromGff( + *root, savedRuntimeIdentityContext()); ASSERT_EQ(queue.events.size(), 5); EXPECT_EQ(queue.events[0].eventId, 1); @@ -1192,7 +1390,10 @@ TEST(SavedEventQueue, should_bind_event_references_only_through_current_B_regist auto target = game.newCreature(); auto caller = game.newCreature(); - SavedEventRecord record = SavedEventRecord::fromGff(*event(1, 1, 2, savedSituation())); + registerModuleIdentity(game, 2, target); + registerModuleIdentity(game, 3, caller); + SavedEventRecord record = SavedEventRecord::fromGff( + *event(1, 1, 2, savedSituation()), savedRuntimeIdentityContext()); EXPECT_TRUE(record.bindObjectReferences(game)); EXPECT_EQ(record.object.boundObject(), target); @@ -1248,7 +1449,8 @@ TEST(SavedEventQueue, should_preserve_observed_spell_body_bag_and_script_event_p {event(8, 1, 10, spell), event(17, 1, 11, bodyBag), event(10, 1, 12, scriptEvent)})) .build(); - SavedEventQueue queue = SavedEventQueue::fromGff(*root); + SavedEventQueue queue = SavedEventQueue::fromGff( + *root, savedRuntimeIdentityContext()); ASSERT_EQ(queue.events.size(), 3); const auto &savedSpell = std::get(queue.events[0].payload); @@ -1270,4 +1472,69 @@ TEST(SavedEventQueue, should_preserve_observed_spell_body_bag_and_script_event_p EXPECT_EQ(savedScriptEvent.objects[0].id, 6); } +TEST(SavedEventQueue, recovered_reference_bearing_payloads_bind_explicitly) { + auto aoo = Gff::Builder().type(0x3333) + .field(Gff::Field::newDword("Value", 4)).build(); + auto combat = Gff::Builder().type(0x2222) + .field(Gff::Field::newDword("ReactObject", 5)) + .field(Gff::Field::newDword("AmmoItem", 6)) + .field(Gff::Field::newInt("AttackType", 17)).build(); + auto feedbackObject1 = Gff::Builder().type(0xbaad) + .field(Gff::Field::newDword("ObjectValue", 7)).build(); + auto feedbackObject2 = Gff::Builder().type(0xbaad) + .field(Gff::Field::newDword("ObjectValue", 8)).build(); + auto feedback = Gff::Builder().type(0xcccc) + .field(Gff::Field::newByte("Type", 9)) + .field(Gff::Field::newList( + "ObjectIDList", {feedbackObject1, feedbackObject2})).build(); + auto spell = Gff::Builder().type(0x6666) + .field(Gff::Field::newDword("CasterId", 4)) + .field(Gff::Field::newDword("TargetId", 5)) + .field(Gff::Field::newDword("AreaId", 6)) + .field(Gff::Field::newDword("ItemId", 7)).build(); + auto root = Gff::Builder().field(Gff::Field::newList( + "EventQueue", + {event(20, 1, 10, aoo), + event(15, 1, 11, combat), + event(21, 1, 12, combat), + event(22, 1, 13, feedback), + event(19, 1, 14, spell)})).build(); + + TestEngine &engine = testEngine(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + std::map> objects; + for (uint32_t id = 2; id <= 8; ++id) { + objects.emplace(id, game.newCreature()); + registerModuleIdentity(game, id, objects.at(id)); + } + auto queue = SavedEventQueue::fromGff( + *root, savedRuntimeIdentityContext()); + + ASSERT_EQ(queue.events.size(), 5u); + for (auto &record : queue.events) { + EXPECT_TRUE(record.bindObjectReferences(game)); + } + const auto &savedAoo = std::get( + queue.events[0].payload); + EXPECT_EQ(savedAoo.target.boundObject(), objects.at(4)); + const auto &melee = std::get( + queue.events[1].payload); + EXPECT_EQ(melee.reactionObject.boundObject(), objects.at(5)); + EXPECT_EQ(melee.ammoItem.boundObject(), objects.at(6)); + const auto &projectile = std::get( + queue.events[2].payload); + EXPECT_EQ(projectile.reactionObject.boundObject(), objects.at(5)); + EXPECT_EQ(projectile.ammoItem.boundObject(), objects.at(6)); + const auto &message = std::get( + queue.events[3].payload); + ASSERT_EQ(message.objects.size(), 2u); + EXPECT_EQ(message.objects[0].boundObject(), objects.at(7)); + EXPECT_EQ(message.objects[1].boundObject(), objects.at(8)); + const auto &itemSpell = std::get( + queue.events[4].payload); + EXPECT_EQ(itemSpell.caster.boundObject(), objects.at(4)); + EXPECT_EQ(itemSpell.item.boundObject(), objects.at(7)); +} + } // namespace diff --git a/test/game/savedsituation.cpp b/test/game/savedsituation.cpp index 6589beb4f..32fb90237 100644 --- a/test/game/savedsituation.cpp +++ b/test/game/savedsituation.cpp @@ -17,6 +17,7 @@ #include "reone/game/game.h" #include "reone/game/location.h" +#include "reone/game/modulesnapshot.h" #include "reone/game/talent.h" #include "reone/game/script/runner.h" #include "reone/game/script/savedsituation.h" @@ -180,6 +181,7 @@ TEST_F(SavedSituationTest, new_runtime_continuation_exposes_future_export_seam) TEST_F(SavedSituationTest, translates_every_supported_stack_value_without_losing_engine_payloads) { auto fixture = continuationProgram(); auto situation = situationFor(fixture); + auto runtimeObject = game.newCreature(); EffectInstance effect; effect.id = 77; effect.retailType = static_cast(EffectType::Disease); @@ -216,7 +218,8 @@ TEST_F(SavedSituationTest, translates_every_supported_stack_value_without_losing {static_cast(SavedVmStackType::Location), SavedLocationValue {glm::vec3(1.0f, 2.0f, 3.0f), glm::vec3(0.0f, 1.0f, 0.0f)}}, {static_cast(SavedVmStackType::Talent), talent}, - {static_cast(SavedVmStackType::Object), SavedObjectReference {1234}}, + {static_cast(SavedVmStackType::Object), + SavedObjectReference {runtimeObject->id()}}, }; auto imported = bindAndImport(situation); @@ -249,11 +252,11 @@ TEST_F(SavedSituationTest, translates_every_supported_stack_value_without_losing EXPECT_EQ(runtimeTalent->type(), TalentType::Feat); EXPECT_EQ(runtimeTalent->value(), 42); EXPECT_EQ(runtimeTalent->multiClass(), 2); - EXPECT_EQ(runtimeTalent->item(), kSavedRuntimeInvalidObjectId); + EXPECT_EQ(runtimeTalent->item(), kObjectInvalid); EXPECT_EQ(runtimeTalent->itemPropertyIndex(), 6); EXPECT_EQ(runtimeTalent->casterLevel(), 12); EXPECT_EQ(runtimeTalent->metaType(), 3); - EXPECT_EQ(state.locals[4].objectId, 1234); + EXPECT_EQ(state.locals[4].objectId, runtimeObject->id()); } TEST_F(SavedSituationTest, saved_effect_vm_value_reuses_effect_instance_when_applied) { @@ -463,7 +466,7 @@ TEST_F(SavedSituationTest, delay_event_and_do_command_action_payloads_share_the_ EXPECT_EQ(event.executionSupport(), SavedExecutionSupport::Executable); } -TEST_F(SavedSituationTest, missing_stack_object_remains_a_safe_raw_identity_for_runtime_resolution) { +TEST_F(SavedSituationTest, missing_runtime_stack_object_fails_closed) { auto fixture = continuationProgram(); auto situation = situationFor(fixture); situation.stack[0] = { @@ -472,8 +475,125 @@ TEST_F(SavedSituationTest, missing_stack_object_remains_a_safe_raw_identity_for_ EXPECT_FALSE(situation.bindObjectReferences(game)); auto imported = SavedScriptSituationImporter(game, scripts).import(situation); + EXPECT_FALSE(imported); + EXPECT_EQ( + imported.error, + SavedScriptSituationImportError::UnboundRuntimeSession); +} + +TEST_F(SavedSituationTest, translates_bound_serialized_stack_references_to_runtime_identity) { + auto fixture = continuationProgram(); + auto situation = situationFor(fixture); + auto target = game.newCreature(); + constexpr uint32_t savedId = 100; + ASSERT_NE(target->id(), savedId); + game.registerSavedObjectIdentity( + savedId, + target, + SerializedIdentityContext::moduleGraph("test-module")); + + EffectInstance effect; + effect.serializedReferenceContext = + SerializedIdentityContext::moduleGraph("test-module"); + effect.creatorId = savedId; + effect.objectParameters[0] = savedId; + SavedScriptEvent event; + event.objects = {SavedObjectReference::fromSerializedId( + savedId, SerializedIdentityContext::moduleGraph("test-module"))}; + SavedTalentValue talent; + talent.type = static_cast(TalentType::Feat); + talent.item = SavedObjectReference::fromSerializedId( + savedId, SerializedIdentityContext::moduleGraph("test-module")); + + situation.stackSize = 4; + situation.basePointer = 4; + situation.stackPointer = 4; + situation.totalSize = 20; + situation.stack = { + {static_cast(SavedVmStackType::Object), + SavedObjectReference::fromSerializedId( + savedId, + SerializedIdentityContext::moduleGraph("test-module"))}, + {static_cast(SavedVmStackType::Event), event}, + {static_cast(SavedVmStackType::Talent), talent}, + {static_cast(SavedVmStackType::Effect), effect}, + }; + + auto imported = bindAndImport(situation); + ASSERT_TRUE(imported) << imported.message; - EXPECT_EQ(imported.continuation->executionState().globals[0].objectId, 123456); + const auto &globals = imported.continuation->executionState().globals; + ASSERT_EQ(globals.size(), 4); + EXPECT_EQ(globals[0].objectId, target->id()); + auto runtimeEvent = std::dynamic_pointer_cast(globals[1].engineType); + ASSERT_TRUE(runtimeEvent); + EXPECT_EQ(runtimeEvent->objects(), (std::vector {target->id()})); + auto runtimeTalent = std::dynamic_pointer_cast(globals[2].engineType); + ASSERT_TRUE(runtimeTalent); + EXPECT_EQ(runtimeTalent->item(), target->id()); + auto savedEffect = std::dynamic_pointer_cast(globals[3].engineType); + ASSERT_TRUE(savedEffect); + EXPECT_EQ(savedEffect->instance().creatorId, target->id()); + EXPECT_EQ(savedEffect->instance().objectParameters[0], target->id()); + EXPECT_FALSE(savedEffect->instance().hasSerializedObjectReferences()); +} + +TEST_F(SavedSituationTest, missing_serialized_stack_object_fails_closed) { + auto fixture = continuationProgram(); + auto situation = situationFor(fixture); + situation.stack[0] = { + static_cast(SavedVmStackType::Object), + SavedObjectReference::fromSerializedId( + 123456, + SerializedIdentityContext::moduleGraph("test-module"))}; + + EXPECT_FALSE(situation.bindObjectReferences(game)); + auto imported = SavedScriptSituationImporter(game, scripts).import(situation); + + EXPECT_FALSE(imported); + EXPECT_EQ( + imported.error, + SavedScriptSituationImportError::UnboundRuntimeSession); +} + +TEST_F(SavedSituationTest, detached_reference_never_uses_active_module_saved_map) { + auto fixture = continuationProgram(); + auto situation = situationFor(fixture); + auto moduleObject = game.newCreature(); + game.registerSavedObjectIdentity( + 100u, + moduleObject, + SerializedIdentityContext::moduleGraph("test-module")); + situation.stack[0] = { + static_cast(SavedVmStackType::Object), + SavedObjectReference::fromSerializedId( + 100u, + SerializedIdentityContext::detachedRecord("availnpc0.utc"))}; + + EXPECT_FALSE(situation.bindObjectReferences(game)); + auto imported = SavedScriptSituationImporter(game, scripts).import(situation); + EXPECT_FALSE(imported); + EXPECT_FALSE( + std::get(situation.stack[0].payload) + .boundObject()); +} + +TEST_F(SavedSituationTest, detached_reference_resolves_only_inside_its_record) { + auto fixture = continuationProgram(); + auto situation = situationFor(fixture); + const auto context = + SerializedIdentityContext::detachedRecord("availnpc0.utc"); + auto localItem = game.newItem(); + localItem->assignSerializedObjectIdentity({context, 100u}); + situation.stack[0] = { + static_cast(SavedVmStackType::Object), + SavedObjectReference::fromSerializedId(100u, context)}; + + auto imported = bindAndImport(situation); + ASSERT_TRUE(imported) << imported.message; + EXPECT_EQ( + imported.continuation->executionState().globals[0].objectId, + localItem->id()); } TEST_F(SavedSituationTest, ordinary_script_runner_path_remains_unchanged) { diff --git a/test/game/savegame.cpp b/test/game/savegame.cpp index 23f864a14..d1280e664 100644 --- a/test/game/savegame.cpp +++ b/test/game/savegame.cpp @@ -13,9 +13,12 @@ #include "reone/game/object/creature.h" #include "reone/game/object/module.h" #include "reone/graphics/format/tgareader.h" +#include "reone/resource/2da.h" #include "reone/resource/exception/notfound.h" #include "reone/resource/format/gffwriter.h" +#include "reone/resource/layout.h" #include "reone/resource/saveworkingstate.h" +#include "reone/scene/node/camera.h" #include "reone/system/stream/memoryinput.h" using namespace reone; @@ -89,8 +92,9 @@ void reone::game::TestGameModule::processPendingSave(Game &game) { game.processPendingSave(); } -bool reone::game::TestGameModule::storeCurrentModuleForTransition(Game &game) { - return game.storeCurrentModuleForTransition(); +std::shared_ptr +reone::game::TestGameModule::prepareCurrentModuleWorkingState(Game &game) { + return game.prepareCurrentModuleWorkingState(); } void reone::game::TestGameModule::setSnapshotModuleName( @@ -130,6 +134,25 @@ void reone::game::TestGameModule::setSaveInProgress( game._saveInProgress = inProgress; } +void reone::game::TestGameModule::inspectPreparedSaveLoad( + Game &game, + const SaveSlotDescriptor &slot, + int &context, + std::string &playerTag, + bool &playerHasObjectId, + std::string &startWaypoint, + uint32_t &pauseDay, + uint32_t &pauseTime) { + auto prepared = game.prepareSaveLoad(slot); + context = static_cast(prepared.destination.context); + const auto &players = prepared.playerInfo->getList("Mod_PlayerList"); + playerTag = players.front()->getString("Tag"); + playerHasObjectId = players.front()->has("ObjectId"); + startWaypoint = prepared.autosave->startWaypoint; + pauseDay = prepared.autosave->pauseDay; + pauseTime = prepared.autosave->pauseTime; +} + namespace { ModuleSnapshotResult moduleSnapshot( @@ -193,6 +216,21 @@ struct SaveFixture : Test { .Times(AnyNumber()) .WillRepeatedly(Return(std::set {})); + destinationIfo = Gff::Builder() + .field(Gff::Field::newResRef( + "Mod_Entry_Area", "area_b")) + .build(); + destinationAre = Gff::Builder().build(); + destinationGit = Gff::Builder().build(); + EXPECT_CALL(director, prepareModuleLoad(_, _)) + .Times(AnyNumber()) + .WillRepeatedly(Invoke([this]( + const std::string &name, + std::shared_ptr) { + return PreparedModuleLoadTestFactory::validated( + name, destinationIfo, destinationAre, destinationGit); + })); + SaveOrchestrationSeams seams; seams.captureModule = [this](const Game &, const std::string &group) { capturedGroups.push_back(group); @@ -260,6 +298,9 @@ struct SaveFixture : Test { uint8_t snapshotSequence {0}; bool publicationFailure {false}; bool cleanupPending {false}; + std::shared_ptr destinationIfo; + std::shared_ptr destinationAre; + std::shared_ptr destinationGit; }; TEST_F(SaveFixture, first_unsaved_manual_save_is_deferred_and_adopted_without_reload) { @@ -610,11 +651,17 @@ TEST_F(SaveFixture, screenshot_capture_failure_is_nonfatal_and_never_reuses_stal TEST_F(SaveFixture, transition_working_state_is_normalized_across_a_b_a_without_disk) { TestGameModule::setSnapshotModuleName(*game, "module_a"); - ASSERT_TRUE(TestGameModule::storeCurrentModuleForTransition(*game)); + committed = TestGameModule::prepareCurrentModuleWorkingState(*game); + ASSERT_TRUE(committed); + engine.resourceModule().director().adoptSaveWorkingState(committed); TestGameModule::setSnapshotModuleName(*game, "module_b"); - ASSERT_TRUE(TestGameModule::storeCurrentModuleForTransition(*game)); + committed = TestGameModule::prepareCurrentModuleWorkingState(*game); + ASSERT_TRUE(committed); + engine.resourceModule().director().adoptSaveWorkingState(committed); TestGameModule::setSnapshotModuleName(*game, "module_a"); - ASSERT_TRUE(TestGameModule::storeCurrentModuleForTransition(*game)); + committed = TestGameModule::prepareCurrentModuleWorkingState(*game); + ASSERT_TRUE(committed); + engine.resourceModule().director().adoptSaveWorkingState(committed); EXPECT_EQ(3, workingAdoptions); EXPECT_TRUE(committed->contains({"module_a", ResType::Sav})); @@ -642,6 +689,39 @@ TEST_F(SaveFixture, transition_snapshot_failure_aborts_before_source_teardown) { EXPECT_EQ(0, workingAdoptions); } +TEST_F(SaveFixture, invalid_destination_is_rejected_before_source_exit_or_snapshot) { + auto sourceModule = game->module(); + auto sourcePlayer = game->party().player(); + auto &director = engine.resourceModule().director(); + EXPECT_CALL(director, prepareModuleLoad("module_b", _)) + .WillOnce(Throw(ResourceNotFoundException("destination missing"))); + EXPECT_CALL(director, commitModuleLoad(_)).Times(0); + + EXPECT_FALSE(game->loadModule("module_b")); + + EXPECT_TRUE(capturedGroups.empty()); + EXPECT_EQ(0, workingAdoptions); + EXPECT_EQ(sourceModule, game->module()); + EXPECT_EQ(sourcePlayer, game->party().player()); + EXPECT_EQ(sourcePlayer, game->getObjectById(sourcePlayer->id())); + EXPECT_TRUE(game->hasPlayableRuntimeSession()); +} + +TEST_F(SaveFixture, failed_destination_commit_does_not_adopt_source_working_state) { + engine.options().graphics.sceneRender = false; + auto &director = engine.resourceModule().director(); + EXPECT_CALL(director, commitModuleLoad(_)) + .WillOnce(Throw(ResourceNotFoundException( + "prepared module changed before commit"))); + + EXPECT_FALSE(game->loadModule("module_b")); + + EXPECT_EQ(0, workingAdoptions); + EXPECT_EQ(Game::Screen::MainMenu, game->currentScreen()); + EXPECT_FALSE(game->module()); + EXPECT_FALSE(game->hasPlayableRuntimeSession()); +} + TEST_F(SaveFixture, reconstruction_is_distinct_from_an_absent_playable_session) { TestGameModule::setRuntimeSessionPlayable(*game, false); @@ -759,11 +839,242 @@ struct LoadTransactionFixture : TestWithParam { std::shared_ptr player; std::vector portraitRows; SaveSlotDescriptor slot; + std::shared_ptr destinationIfo; + std::shared_ptr destinationAre; + std::shared_ptr destinationGit; + std::shared_ptr emptyLayout {std::make_shared()}; + std::vector> cameraNodes; + int spawnScriptLookups {0}; + ByteBuffer activeNpcBytes; + + static std::shared_ptr appearanceTable() { + TwoDA::Builder builder; + builder.columns({ + "modeltype", "walkdist", "rundist", "footsteptype", + "envmap", "race", "racetex"}); + builder.row({"S", "1", "1", "-1", "", "", ""}); + return std::shared_ptr(builder.build()); + } + + std::unique_ptr preparedDestination( + const std::string &name = "module_b") { + return PreparedModuleLoadTestFactory::validated( + name, destinationIfo, destinationAre, destinationGit); + } + + void writeRequiredMetadata() { + auto nfo = Gff::Builder() + .field(Gff::Field::newCExoString( + "LASTMODULE", "module_b")) + .field(Gff::Field::newCExoString( + "AREANAME", "module_b")) + .build(); + auto nfoBytes = GffWriter( + GffFileFormat::v32("NFO "), *nfo) + .toBytes(); + std::ofstream nfoFile( + slot.directory / "savenfo.res", std::ios::binary); + nfoFile.write( + nfoBytes.data(), static_cast(nfoBytes.size())); + + auto globals = Gff::Builder().build(); + auto globalBytes = GffWriter( + GffFileFormat::v32("GVT "), *globals) + .toBytes(); + std::ofstream globalFile( + slot.directory / "globalvars.res", std::ios::binary); + globalFile.write( + globalBytes.data(), + static_cast(globalBytes.size())); + } + + void writeTemplateAutosaveMetadata( + std::string playerTag = "jolee", + std::string startWaypoint = "autosave_start", + uint32_t pauseDay = 7, + uint32_t pauseTime = 4321) { + auto params = Gff::Builder() + .field(Gff::Field::newCExoString( + "STARTWAYPOINT", std::move(startWaypoint))) + .field(Gff::Field::newDword( + "TIME_PAUSEDAY", pauseDay)) + .field(Gff::Field::newDword( + "TIME_PAUSETIME", pauseTime)) + .build(); + auto nfo = Gff::Builder() + .field(Gff::Field::newCExoString( + "LASTMODULE", "module_b")) + .field(Gff::Field::newCExoString( + "AREANAME", "module_b")) + .field(Gff::Field::newByte("PCAUTOSAVE", 1)) + .field(Gff::Field::newStruct( + "AUTOSAVEPARAMS", params)) + .build(); + auto nfoBytes = GffWriter( + GffFileFormat::v32("NFO "), *nfo) + .toBytes(); + std::ofstream nfoFile( + slot.directory / "savenfo.res", std::ios::binary); + nfoFile.write( + nfoBytes.data(), static_cast(nfoBytes.size())); + + auto globals = Gff::Builder().build(); + auto globalBytes = GffWriter( + GffFileFormat::v32("GVT "), *globals) + .toBytes(); + std::ofstream globalFile( + slot.directory / "globalvars.res", std::ios::binary); + globalFile.write( + globalBytes.data(), + static_cast(globalBytes.size())); + + auto savedPlayer = Gff::Builder() + .field(Gff::Field::newCExoString( + "Tag", std::move(playerTag))) + .field(Gff::Field::newDword( + "Appearance_Type", 0)) + .field(Gff::Field::newWord( + "SoundSetFile", 0xffff)) + .field(Gff::Field::newByte( + "BodyBag", 0xff)) + .field(Gff::Field::newByte( + "PerceptionRange", 0xff)) + .build(); + auto pifo = Gff::Builder() + .field(Gff::Field::newList( + "Mod_PlayerList", {savedPlayer})) + .build(); + auto pifoBytes = GffWriter( + GffFileFormat::v32("IFO "), *pifo) + .toBytes(); + std::ofstream pifoFile( + slot.directory / "pifo.ifo", std::ios::binary); + pifoFile.write( + pifoBytes.data(), static_cast(pifoBytes.size())); + + auto member = Gff::Builder() + .field(Gff::Field::newInt( + "PT_MEMBER_ID", kNpcPlayer)) + .field(Gff::Field::newByte( + "PT_IS_LEADER", 1)) + .build(); + auto party = Gff::Builder() + .field(Gff::Field::newInt( + "PT_CONTROLLED_NP", -1)) + .field(Gff::Field::newByte( + "PT_NUM_MEMBERS", 1)) + .field(Gff::Field::newList( + "PT_MEMBERS", {member})) + .build(); + auto partyBytes = GffWriter( + GffFileFormat::v32("PT "), *party) + .toBytes(); + std::ofstream partyFile( + slot.directory / "partytable.res", std::ios::binary); + partyFile.write( + partyBytes.data(), static_cast(partyBytes.size())); + } + + void writeActiveNpcWithSavedAction() { + auto playerMember = Gff::Builder() + .field(Gff::Field::newInt( + "PT_MEMBER_ID", kNpcPlayer)) + .field(Gff::Field::newByte( + "PT_IS_LEADER", 1)) + .build(); + auto npcMember = Gff::Builder() + .field(Gff::Field::newInt("PT_MEMBER_ID", 0)) + .field(Gff::Field::newByte("PT_IS_LEADER", 0)) + .build(); + auto available = Gff::Builder() + .field(Gff::Field::newByte("PT_NPC_AVAIL", 1)) + .field(Gff::Field::newByte("PT_NPC_SELECT", 1)) + .build(); + auto party = Gff::Builder() + .field(Gff::Field::newInt("PT_CONTROLLED_NP", -1)) + .field(Gff::Field::newByte("PT_NUM_MEMBERS", 2)) + .field(Gff::Field::newList( + "PT_MEMBERS", {playerMember, npcMember})) + .field(Gff::Field::newList( + "PT_AVAIL_NPCS", {available})) + .build(); + auto partyBytes = GffWriter( + GffFileFormat::v32("PT "), *party) + .toBytes(); + std::ofstream partyFile( + slot.directory / "partytable.res", std::ios::binary); + partyFile.write( + partyBytes.data(), static_cast(partyBytes.size())); + partyFile.close(); + + auto seconds = Gff::Builder() + .type(1) + .field(Gff::Field::newDword("Type", 2)) + .field(Gff::Field::newFloat("Value", 5.0f)) + .build(); + auto wait = Gff::Builder() + .type(0) + .field(Gff::Field::newDword("ActionId", 30)) + .field(Gff::Field::newWord("GroupActionId", 1)) + .field(Gff::Field::newWord("NumParams", 1)) + .field(Gff::Field::newList("Paramaters", {seconds})) + .build(); + auto npc = Gff::Builder() + .field(Gff::Field::newCExoString("Tag", "disk_npc")) + .field(Gff::Field::newDword("Appearance_Type", 0)) + .field(Gff::Field::newWord("SoundSetFile", 0xffff)) + .field(Gff::Field::newByte("BodyBag", 0xff)) + .field(Gff::Field::newByte("PerceptionRange", 0xff)) + .field(Gff::Field::newList("ActionList", {wait})) + .build(); + activeNpcBytes = GffWriter( + GffFileFormat::v32("UTC "), *npc) + .toBytes(); + test::writeErf( + slot.archive, + ErfWriter::FileType::ERF, + {{"availnpc0", ResType::Utc, + std::string(activeNpcBytes.begin(), activeNpcBytes.end())}}); + } void SetUp() override { engine.init(); + engine.options().graphics.sceneRender = false; ON_CALL(engine.sceneModule().graphs(), get(_)) .WillByDefault(ReturnRef(sceneGraph)); + ON_CALL(sceneGraph, newCamera()) + .WillByDefault(Invoke([this]() { + auto node = std::make_shared( + sceneGraph, + engine.services().graphics, + engine.services().audio, + engine.services().resource); + cameraNodes.push_back(node); + return node; + })); + EXPECT_CALL(engine.resourceModule().layouts(), get(_)) + .Times(AnyNumber()) + .WillRepeatedly(Return(emptyLayout)); + EXPECT_CALL(engine.resourceModule().strings(), getText(_)) + .Times(AnyNumber()) + .WillRepeatedly(Return(std::string {})); + EXPECT_CALL(engine.resourceModule().twoDas(), get(_)) + .Times(AnyNumber()) + .WillRepeatedly(Return(nullptr)); + EXPECT_CALL(engine.resourceModule().twoDas(), get("appearance")) + .Times(AnyNumber()) + .WillRepeatedly(Return(appearanceTable())); + EXPECT_CALL(engine.resourceModule().models(), get(_)) + .Times(AnyNumber()); + EXPECT_CALL(engine.resourceModule().scripts(), get(_)) + .Times(AnyNumber()) + .WillRepeatedly(Invoke([this](const std::string &resRef) + -> std::shared_ptr { + if (resRef == "spawn_probe") { + ++spawnScriptLookups; + } + return nullptr; + })); portraitRows.push_back({"po_live_player", 0, -1, -1, true, 0}); EXPECT_CALL(engine.gameModule().portraits(), portraits()) .Times(AnyNumber()) @@ -771,6 +1082,7 @@ struct LoadTransactionFixture : TestWithParam { game = std::make_unique( GetParam(), root.path, engine.options(), engine.services(), console); + TestGameModule::initSnapshotLocalServices(*game); area = game->newArea(); player = game->newCreature(); TestGameModule::configureModuleSnapshot( @@ -785,6 +1097,41 @@ struct LoadTransactionFixture : TestWithParam { std::filesystem::create_directories(directory); test::writeErf(directory / "SAVEGAME.sav", ErfWriter::FileType::ERF, {}); slot = SaveSlotDescriptor {directory, directory / "SAVEGAME.sav"}; + + auto playerRecord = Gff::Builder() + .field(Gff::Field::newDword("ObjectId", 100)) + .build(); + auto areaRecord = Gff::Builder() + .field(Gff::Field::newResRef( + "Area_Name", "module_b")) + .field(Gff::Field::newDword("ObjectId", 101)) + .build(); + destinationIfo = Gff::Builder() + .field(Gff::Field::newResRef( + "Mod_Entry_Area", "module_b")) + .field(Gff::Field::newByte("Mod_IsSaveGame", 1)) + .field(Gff::Field::newDword( + "Mod_NextObjId0", 2)) + .field(Gff::Field::newDword64( + "Mod_Effect_NxtId", 1)) + .field(Gff::Field::newList( + "Mod_PlayerList", {playerRecord})) + .field(Gff::Field::newList( + "Mod_Area_list", {areaRecord})) + .build(); + destinationAre = Gff::Builder().build(); + destinationGit = Gff::Builder().build(); + + auto &director = engine.resourceModule().director(); + EXPECT_CALL(director, prepareModuleLoad(_, _)) + .Times(AnyNumber()) + .WillRepeatedly(Invoke([this]( + const std::string &name, + std::shared_ptr) { + return preparedDestination(name); + })); + EXPECT_CALL(director, commitModuleLoad(_)) + .Times(AnyNumber()); } }; @@ -839,6 +1186,105 @@ TEST_P(LoadTransactionFixture, aMissingRequiredSaveRecordFailsBeforeTheCommitBou EXPECT_EQ(modulePtr, game->module().get()); } +TEST_P(LoadTransactionFixture, invalidDestinationStructureLeavesTheRunningWorldUntouched) { + writeRequiredMetadata(); + destinationGit.reset(); + + auto &director = engine.resourceModule().director(); + EXPECT_CALL(director, prepareGameLoad(_)) + .WillOnce(Invoke([](const SaveSlotDescriptor &descriptor) { + return std::make_unique(descriptor); + })); + EXPECT_CALL(director, commitGameLoad(_)).Times(0); + EXPECT_CALL(director, onNewGame()).Times(0); + + auto generation = TestGameModule::runtimeSessionGeneration(*game); + auto moduleBefore = game->module(); + auto playerBefore = game->party().player(); + auto screenBefore = game->currentScreen(); + + EXPECT_THROW(game->loadGame(slot), ResourceNotFoundException); + + EXPECT_EQ(generation, TestGameModule::runtimeSessionGeneration(*game)); + EXPECT_EQ(moduleBefore, game->module()); + EXPECT_EQ(playerBefore, game->party().player()); + EXPECT_EQ(playerBefore, game->getObjectById(playerBefore->id())); + EXPECT_EQ(screenBefore, game->currentScreen()); +} + +TEST_P(LoadTransactionFixture, duplicateSavedIdentityFailsBeforeSessionPublication) { + writeRequiredMetadata(); + auto duplicatePlayer = Gff::Builder() + .field(Gff::Field::newDword("ObjectId", 101)) + .build(); + auto duplicateArea = Gff::Builder() + .field(Gff::Field::newResRef( + "Area_Name", "module_b")) + .field(Gff::Field::newDword("ObjectId", 101)) + .build(); + destinationIfo = Gff::Builder() + .field(Gff::Field::newResRef( + "Mod_Entry_Area", "module_b")) + .field(Gff::Field::newByte("Mod_IsSaveGame", 1)) + .field(Gff::Field::newList( + "Mod_PlayerList", {duplicatePlayer})) + .field(Gff::Field::newList( + "Mod_Area_list", {duplicateArea})) + .build(); + + auto &director = engine.resourceModule().director(); + EXPECT_CALL(director, prepareGameLoad(_)) + .WillOnce(Invoke([](const SaveSlotDescriptor &descriptor) { + return std::make_unique(descriptor); + })); + EXPECT_CALL(director, commitGameLoad(_)).Times(0); + EXPECT_CALL(director, onNewGame()).Times(0); + + auto generation = TestGameModule::runtimeSessionGeneration(*game); + auto moduleBefore = game->module(); + auto playerBefore = game->party().player(); + + EXPECT_THROW(game->loadGame(slot), ValidationException); + + EXPECT_EQ(generation, TestGameModule::runtimeSessionGeneration(*game)); + EXPECT_EQ(moduleBefore, game->module()); + EXPECT_EQ(playerBefore, game->party().player()); +} + +TEST_P(LoadTransactionFixture, invalidPartyCandidateDoesNotMutatePublishedParty) { + writeRequiredMetadata(); + auto partyTable = Gff::Builder() + .field(Gff::Field::newInt( + "PT_CONTROLLED_NP", 999)) + .build(); + auto partyBytes = GffWriter( + GffFileFormat::v32("PT "), *partyTable) + .toBytes(); + std::ofstream partyFile( + slot.directory / "partytable.res", std::ios::binary); + partyFile.write( + partyBytes.data(), + static_cast(partyBytes.size())); + partyFile.close(); + + auto &director = engine.resourceModule().director(); + EXPECT_CALL(director, prepareGameLoad(_)) + .WillOnce(Invoke([](const SaveSlotDescriptor &descriptor) { + return std::make_unique(descriptor); + })); + EXPECT_CALL(director, commitGameLoad(_)).Times(0); + EXPECT_CALL(director, onNewGame()).Times(0); + + auto generation = TestGameModule::runtimeSessionGeneration(*game); + auto playerBefore = game->party().player(); + + EXPECT_THROW(game->loadGame(slot), ValidationException); + + EXPECT_EQ(generation, TestGameModule::runtimeSessionGeneration(*game)); + EXPECT_EQ(playerBefore, game->party().player()); + EXPECT_EQ(playerBefore, game->getObjectById(playerBefore->id())); +} + TEST_P(LoadTransactionFixture, preparationConsumesTheDiscoveredDescriptorVerbatim) { // given the slot discovered by indexing. Reducing it to a name here would // let the loader resolve a different directory than the list offered. @@ -901,11 +1347,11 @@ TEST_P(LoadTransactionFixture, aPostCommitFailureLandsOnADeliberateScreen) { return std::make_unique(descriptor); })); EXPECT_CALL(director, commitGameLoad(_)).Times(1); - EXPECT_CALL(director, onModuleLoad(_)) - .WillRepeatedly(Throw(ResourceNotFoundException("module not found"))); + EXPECT_CALL(director, commitModuleLoad(_)) + .WillOnce(Throw(ResourceNotFoundException("module changed before commit"))); // when the failure lands after the commit boundary - EXPECT_NO_THROW(game->loadGame(slot)); + EXPECT_FALSE(game->loadGame(slot)); // then nothing of either session is left half-built, and the engine is not // sitting on the blank screen an abandoned session renders as @@ -915,6 +1361,208 @@ TEST_P(LoadTransactionFixture, aPostCommitFailureLandsOnADeliberateScreen) { EXPECT_FALSE(game->hasPlayableRuntimeSession()); } +TEST_P(LoadTransactionFixture, saveResourceCommitFailureAlsoLandsOnADeliberateScreen) { + writeRequiredMetadata(); + + auto &director = engine.resourceModule().director(); + EXPECT_CALL(director, prepareGameLoad(_)) + .WillOnce(Invoke([](const SaveSlotDescriptor &descriptor) { + return std::make_unique(descriptor); + })); + EXPECT_CALL(director, commitGameLoad(_)) + .WillOnce(Throw(ResourceNotFoundException( + "candidate save changed before commit"))); + EXPECT_CALL(director, commitModuleLoad(_)).Times(0); + + EXPECT_FALSE(game->loadGame(slot)); + + EXPECT_EQ(Game::Screen::MainMenu, game->currentScreen()); + EXPECT_FALSE(game->module()); + EXPECT_FALSE(game->hasPlayableRuntimeSession()); +} + +TEST_P(LoadTransactionFixture, installedModuleFallbackRemainsValidAtPreflight) { + writeTemplateAutosaveMetadata(); + destinationIfo = Gff::Builder() + .field(Gff::Field::newResRef( + "Mod_Entry_Area", "module_b")) + .build(); + destinationGit = Gff::Builder() + .field(Gff::Field::newByte("UseTemplates", 1)) + .build(); + + auto &director = engine.resourceModule().director(); + EXPECT_CALL(director, prepareGameLoad(_)) + .WillOnce(Invoke([](const SaveSlotDescriptor &descriptor) { + return std::make_unique(descriptor); + })); + EXPECT_CALL(director, commitGameLoad(_)).Times(1); + EXPECT_CALL(director, commitModuleLoad(_)) + .WillOnce(Throw(ResourceNotFoundException( + "stop after proving optional module snapshot acceptance"))); + + auto generation = TestGameModule::runtimeSessionGeneration(*game); + + EXPECT_FALSE(game->loadGame(slot)); + + EXPECT_NE(generation, TestGameModule::runtimeSessionGeneration(*game)); + EXPECT_EQ(Game::Screen::MainMenu, game->currentScreen()); +} + +TEST_P(LoadTransactionFixture, templateAutosavePreparationSeparatesSessionAndWorldIdentity) { + writeTemplateAutosaveMetadata("jolee", "autosave_start", 7, 4321); + destinationIfo = Gff::Builder() + .field(Gff::Field::newResRef( + "Mod_Entry_Area", "module_b")) + .field(Gff::Field::newByte("Mod_IsSaveGame", 1)) + .field(Gff::Field::newDword( + "Mod_MinPerHour", 5)) + .build(); + destinationGit = Gff::Builder() + .field(Gff::Field::newByte("UseTemplates", 1)) + .build(); + + auto &director = engine.resourceModule().director(); + EXPECT_CALL(director, prepareGameLoad(_)) + .WillOnce(Invoke([](const SaveSlotDescriptor &descriptor) { + return std::make_unique(descriptor); + })); + EXPECT_CALL(director, commitGameLoad(_)).Times(0); + const auto generation = TestGameModule::runtimeSessionGeneration(*game); + const auto registrySize = TestGameModule::objectRegistrySize(*game); + + int context = -1; + std::string playerTag; + bool playerHasObjectId = true; + std::string startWaypoint; + uint32_t pauseDay = 0; + uint32_t pauseTime = 0; + TestGameModule::inspectPreparedSaveLoad( + *game, + slot, + context, + playerTag, + playerHasObjectId, + startWaypoint, + pauseDay, + pauseTime); + + EXPECT_EQ(static_cast(ModuleLoadContext::InitialTemplateRestore), + context); + EXPECT_EQ("jolee", playerTag); + EXPECT_FALSE(playerHasObjectId); + EXPECT_EQ("autosave_start", startWaypoint); + EXPECT_EQ(7u, pauseDay); + EXPECT_EQ(4321u, pauseTime); + EXPECT_EQ(generation, TestGameModule::runtimeSessionGeneration(*game)); + EXPECT_EQ(registrySize, TestGameModule::objectRegistrySize(*game)); +} + +TEST_P(LoadTransactionFixture, retailShapedTemplateAutosaveRestoresAPlayableFreshWorld) { + writeTemplateAutosaveMetadata("jolee", "autosave_start", 7, 4321); + destinationIfo = Gff::Builder() + .field(Gff::Field::newResRef( + "Mod_Entry_Area", "module_b")) + .field(Gff::Field::newFloat("Mod_Entry_X", 11.0f)) + .field(Gff::Field::newFloat("Mod_Entry_Y", 22.0f)) + .field(Gff::Field::newDword( + "Mod_MinPerHour", 2)) + .build(); + auto spawned = Gff::Builder() + .field(Gff::Field::newCExoString("Tag", "fresh_spawn")) + .field(Gff::Field::newResRef( + "ScriptSpawn", "spawn_probe")) + .field(Gff::Field::newDword( + "Appearance_Type", 0)) + .field(Gff::Field::newWord( + "SoundSetFile", 0xffff)) + .field(Gff::Field::newByte("BodyBag", 0xff)) + .field(Gff::Field::newByte( + "PerceptionRange", 0xff)) + .build(); + auto startWaypoint = Gff::Builder() + .field(Gff::Field::newCExoString( + "Tag", "autosave_start")) + .field(Gff::Field::newFloat( + "XPosition", 33.0f)) + .field(Gff::Field::newFloat( + "YPosition", 44.0f)) + .field(Gff::Field::newFloat( + "XOrientation", 0.0f)) + .field(Gff::Field::newFloat( + "YOrientation", 1.0f)) + .build(); + destinationGit = Gff::Builder() + .field(Gff::Field::newByte("UseTemplates", 1)) + .field(Gff::Field::newList( + "Creature List", {spawned})) + .field(Gff::Field::newList( + "WaypointList", {startWaypoint})) + .build(); + + auto &director = engine.resourceModule().director(); + EXPECT_CALL(director, prepareGameLoad(_)) + .WillOnce(Invoke([](const SaveSlotDescriptor &descriptor) { + return std::make_unique(descriptor); + })); + EXPECT_CALL(director, commitGameLoad(_)).Times(1); + + ASSERT_TRUE(game->loadGame(slot)); + + EXPECT_TRUE(game->hasPlayableRuntimeSession()); + EXPECT_EQ(Game::Screen::InGame, game->currentScreen()); + ASSERT_TRUE(game->module()); + EXPECT_EQ("module_b", game->module()->name()); + ASSERT_TRUE(game->party().player()); + EXPECT_EQ("jolee", game->party().player()->tag()); + EXPECT_FALSE(game->party().player()->serializedObjectIdentity()); + EXPECT_EQ(game->party().player(), + game->module()->area()->getObjectByTag("jolee")); + EXPECT_EQ(7u, game->worldTimeDay()); + EXPECT_EQ(4321u, game->worldTimeOfDay()); + EXPECT_FLOAT_EQ(33.0f, game->party().player()->position().x); + EXPECT_FLOAT_EQ(44.0f, game->party().player()->position().y); + EXPECT_EQ(1, spawnScriptLookups); + auto fresh = game->module()->area()->getObjectByTag("fresh_spawn"); + ASSERT_TRUE(fresh); + EXPECT_FALSE(fresh->serializedObjectIdentity()); +} + +TEST_P(LoadTransactionFixture, diskRestoreClearsSpawnedDetachedNpcActions) { + writeTemplateAutosaveMetadata("player", "autosave_start", 7, 4321); + writeActiveNpcWithSavedAction(); + destinationIfo = Gff::Builder() + .field(Gff::Field::newResRef( + "Mod_Entry_Area", "module_b")) + .build(); + destinationGit = Gff::Builder() + .field(Gff::Field::newByte("UseTemplates", 1)) + .build(); + + auto &director = engine.resourceModule().director(); + EXPECT_CALL(director, prepareGameLoad(_)) + .WillOnce(Invoke([](const SaveSlotDescriptor &descriptor) { + return std::make_unique(descriptor); + })); + EXPECT_CALL(director, commitGameLoad(_)).Times(1); + EXPECT_CALL(director, findSaveWorking(_)) + .Times(AnyNumber()) + .WillRepeatedly(Invoke([this](const ResourceId &id) + -> std::optional { + if (id != ResourceId("availnpc0", ResType::Utc)) { + return std::nullopt; + } + return Resource {activeNpcBytes}; + })); + + ASSERT_TRUE(game->loadGame(slot)); + + auto npc = game->party().getMemberByNPC(0); + ASSERT_TRUE(npc); + EXPECT_EQ("disk_npc", npc->tag()); + EXPECT_TRUE(npc->actions().empty()); +} + TEST_P(LoadTransactionFixture, aCommittedLoadReplacesTheRunningSessionInOrder) { // given a candidate that gets past validation auto nfo = Gff::Builder() @@ -942,6 +1590,7 @@ TEST_P(LoadTransactionFixture, aCommittedLoadReplacesTheRunningSessionInOrder) { // InSequence expectation would reject. int step = 0; int prepared = 0; + int modulePrepared = 0; int retired = 0; int committed = 0; @@ -951,6 +1600,13 @@ TEST_P(LoadTransactionFixture, aCommittedLoadReplacesTheRunningSessionInOrder) { prepared = ++step; return std::make_unique(descriptor); })); + EXPECT_CALL(director, prepareModuleLoad("module_b", _)) + .WillOnce(Invoke([&]( + const std::string &name, + std::shared_ptr) { + modulePrepared = ++step; + return preparedDestination(name); + })); EXPECT_CALL(director, onNewGame()) .Times(AnyNumber()) .WillRepeatedly(Invoke([&]() { @@ -962,11 +1618,11 @@ TEST_P(LoadTransactionFixture, aCommittedLoadReplacesTheRunningSessionInOrder) { .WillOnce(Invoke([&](std::unique_ptr) { committed = ++step; })); - EXPECT_CALL(director, onModuleLoad(_)) - .WillRepeatedly(Throw(ResourceNotFoundException("module not found"))); - + EXPECT_CALL(director, commitModuleLoad(_)) + .WillOnce(Throw(ResourceNotFoundException( + "stop after observing session publication"))); // when the load reaches the commit boundary - EXPECT_NO_THROW(game->loadGame(slot)); + EXPECT_FALSE(game->loadGame(slot)); // then the session that was running has genuinely been replaced rather // than merely disturbed @@ -975,9 +1631,11 @@ TEST_P(LoadTransactionFixture, aCommittedLoadReplacesTheRunningSessionInOrder) { // validation precedes retirement, retirement precedes publication ASSERT_GT(prepared, 0); + ASSERT_GT(modulePrepared, 0); ASSERT_GT(retired, 0); ASSERT_GT(committed, 0); EXPECT_LT(prepared, retired); + EXPECT_LT(modulePrepared, retired); EXPECT_LT(retired, committed); } diff --git a/test/game/saveprovenance.cpp b/test/game/saveprovenance.cpp index 9d5036ae1..01f980acb 100644 --- a/test/game/saveprovenance.cpp +++ b/test/game/saveprovenance.cpp @@ -3,6 +3,8 @@ #include #include +#include + #include "../fixtures/engine.h" #include "../fixtures/game.h" @@ -13,7 +15,9 @@ #include "reone/game/object/item.h" #include "reone/game/object/module.h" #include "reone/game/saveprovenance.h" +#include "reone/resource/2da.h" #include "reone/resource/gff.h" +#include "reone/system/exception/validation.h" using namespace reone; using namespace reone::game; @@ -75,6 +79,235 @@ std::shared_ptr savedEvent( return builder.build(); } +std::shared_ptr objectWithItem(uint32_t objectId, uint32_t itemId) { + auto item = Gff::Builder() + .field(Gff::Field::newDword("ObjectId", itemId)) + .build(); + return Gff::Builder() + .field(Gff::Field::newDword("ObjectId", objectId)) + .field(Gff::Field::newList("ItemList", {item})) + .build(); +} + +std::shared_ptr identityTestBaseItems() { + TwoDA::Builder builder; + builder.columns({"itemclass"}); + builder.row({"I_Test"}); + return std::shared_ptr(builder.build()); +} + +std::shared_ptr identityTestAppearances() { + TwoDA::Builder builder; + builder.columns({"modeltype", "walkdist", "rundist", "footsteptype", "envmap", "race", "racetex"}); + builder.row({"S", "1", "1", "-1", "", "", ""}); + return std::shared_ptr(builder.build()); +} + +TEST(SerializedIdentityAuthority, same_structure_has_contextual_authority) { + TestEngine &engine = testEngine(); + StubConsole console; + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .WillRepeatedly(Return(identityTestBaseItems())); + auto record = Gff::Builder() + .field(Gff::Field::newDword("ObjectId", 136)) + .build(); + + Game moduleGame(GameID::KotOR, "", engine.options(), engine.services(), console); + const auto moduleContext = + SerializedIdentityContext::moduleGraph("test-module"); + auto authoritative = moduleGame.newItem(*record, moduleContext); + authoritative->captureSaveRecord(*record, moduleContext); + EXPECT_NE(authoritative->id(), 136u); + EXPECT_EQ(moduleGame.getObjectBySavedId(136), authoritative); + EXPECT_EQ( + authoritative->serializedObjectIdentity(), + std::optional({moduleContext, 136})); + + Game detachedGame(GameID::KotOR, "", engine.options(), engine.services(), console); + const auto detachedContext = + SerializedIdentityContext::detachedRecord("availnpc0.utc"); + auto detached = detachedGame.newItem(*record, detachedContext); + detached->captureSaveRecord(*record, detachedContext); + EXPECT_NE(detached->id(), 136u); + EXPECT_EQ( + detached->serializedObjectIdentity(), + std::optional({detachedContext, 136})); + + Game templateGame(GameID::KotOR, "", engine.options(), engine.services(), console); + auto templated = templateGame.newItem( + *record, SerializedIdentityContext::templateResource("test.uti")); + EXPECT_NE(templated->id(), 136u); +} + +TEST(SavedIdentityTranslation, authoritative_nested_effect_reference_binds_to_fresh_runtime_item) { + TestEngine &engine = testEngine(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .WillRepeatedly(Return(identityTestBaseItems())); + EXPECT_CALL(engine.resourceModule().twoDas(), get("appearance")) + .WillRepeatedly(Return(identityTestAppearances())); + const auto context = + SerializedIdentityContext::moduleGraph("test-module"); + auto item = Gff::Builder().type(0) + .field(Gff::Field::newDword("ObjectId", 136)) + .field(Gff::Field::newInt("BaseItem", 0)).build(); + auto effect = savedEffect(DurationType::Permanent); + replaceSaveField( + *effect, + Gff::Field::newList( + "ObjectList", + {Gff::Builder() + .field(Gff::Field::newDword("Value", 136)) + .build()})); + auto creatureRecord = Gff::Builder().type(4) + .field(Gff::Field::newDword("ObjectId", 40)) + .field(Gff::Field::newList("ItemList", {item})) + .field(Gff::Field::newList("EffectList", {effect})) + .field(Gff::Field::newList("ActionList", {})).build(); + auto git = Gff::Builder() + .field(Gff::Field::newList("Creature List", {creatureRecord})) + .build(); + game.reserveSavedObjectIds(*git, context, SerializedGraphRoot::AreaGit); + + auto creature = game.newCreature(*creatureRecord, context); + creature->captureSaveRecord(*creatureRecord, context); + game.resolveSavedObjectReferences(); + creature->bindSavedRuntimeState(); + + ASSERT_EQ(creature->items().size(), 1u); + auto runtimeItem = creature->items().front(); + EXPECT_NE(runtimeItem->id(), 136u); + EXPECT_EQ(game.getObjectBySavedId(136), runtimeItem); + ASSERT_EQ(creature->savedEffects().size(), 1u); + EXPECT_EQ( + creature->savedEffects().front().boundObjectParameter(0), + runtimeItem); +} + +TEST(SavedIdentityTranslation, matching_runtime_number_does_not_establish_saved_identity) { + TestEngine &engine = testEngine(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + auto unrelatedRuntimeTwo = game.newItem(); + ASSERT_EQ(unrelatedRuntimeTwo->id(), 2u); + auto record = Gff::Builder() + .field(Gff::Field::newDword("ObjectId", 2)).build(); + const auto context = + SerializedIdentityContext::moduleGraph("test-module"); + auto savedTwo = game.newItem(*record, context); + ASSERT_NE(savedTwo->id(), 2u); + + auto reference = SavedObjectReference::fromSerializedId(2, context); + ASSERT_TRUE(game.bindSavedObjectReference(reference)); + EXPECT_EQ(reference.boundObject(), savedTwo); + EXPECT_NE(reference.boundObject(), unrelatedRuntimeTwo); + EXPECT_EQ(game.getObjectById(2), unrelatedRuntimeTwo); + EXPECT_EQ(game.getObjectBySavedId(2), savedTwo); +} + +TEST(SerializedIdentityAuthority, detached_nested_ids_overlap_without_runtime_collision) { + TestEngine &engine = testEngine(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + EXPECT_CALL(engine.resourceModule().twoDas(), get("baseitems")) + .WillRepeatedly(Return(identityTestBaseItems())); + auto firstRecord = objectWithItem(40, 136); + auto secondRecord = objectWithItem(40, 136); + const auto firstContext = + SerializedIdentityContext::detachedRecord("availnpc0.utc"); + const auto secondContext = + SerializedIdentityContext::detachedRecord("availnpc7.utc"); + + auto first = std::make_shared(40, game, engine.services()); + auto second = std::make_shared(41, game, engine.services()); + first->deserialize(*firstRecord, firstContext); + second->deserialize(*secondRecord, secondContext); + + ASSERT_EQ(first->items().size(), 1u); + ASSERT_EQ(second->items().size(), 1u); + EXPECT_NE(first->items().front()->id(), second->items().front()->id()); + EXPECT_EQ( + first->items().front()->serializedObjectIdentity(), + std::optional({firstContext, 136})); + EXPECT_EQ( + second->items().front()->serializedObjectIdentity(), + std::optional({secondContext, 136})); +} + +TEST(SerializedIdentityAuthority, authority_propagates_to_nested_owned_graphs) { + auto creature = objectWithItem(10, 136); + auto equipped = Gff::Builder() + .field(Gff::Field::newDword("ObjectId", 139)) + .build(); + creature->fields().push_back( + Gff::Field::newList("Equip_ItemList", {equipped})); + auto placeable = objectWithItem(20, 137); + auto store = objectWithItem(30, 138); + auto unrelatedReference = Gff::Builder() + .field(Gff::Field::newDword("ObjectId", 999)) + .build(); + auto git = Gff::Builder() + .field(Gff::Field::newList("Creature List", {creature})) + .field(Gff::Field::newList("Placeable List", {placeable})) + .field(Gff::Field::newList("StoreList", {store})) + .field(Gff::Field::newList("EventQueue", {unrelatedReference})) + .build(); + const auto moduleContext = + SerializedIdentityContext::moduleGraph("test-module"); + + auto claims = collectSerializedObjectIdClaims( + *git, moduleContext, SerializedGraphRoot::AreaGit); + std::set ids; + for (const auto &claim : claims) ids.insert(claim.id); + EXPECT_EQ(ids, std::set({10, 20, 30, 136, 137, 138, 139})); + EXPECT_TRUE(collectSerializedObjectIdClaims( + *git, + SerializedIdentityContext::detachedRecord("availnpc.utc"), + SerializedGraphRoot::AreaGit) + .empty()); + EXPECT_TRUE(collectSerializedObjectIdClaims( + *git, + SerializedIdentityContext::templateResource("test.git"), + SerializedGraphRoot::AreaGit) + .empty()); +} + +TEST(SerializedIdentityAuthority, authoritative_duplicates_remain_errors) { + TestEngine &engine = testEngine(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + auto creature = objectWithItem(10, 136); + auto store = objectWithItem(30, 136); + auto git = Gff::Builder() + .field(Gff::Field::newList("Creature List", {creature})) + .field(Gff::Field::newList("StoreList", {store})) + .build(); + const auto moduleContext = + SerializedIdentityContext::moduleGraph("test-module"); + + EXPECT_THROW( + game.reserveSavedObjectIds( + *git, moduleContext, SerializedGraphRoot::AreaGit), + ValidationException); +} + +TEST(SerializedIdentityAuthority, detached_traversal_does_not_reserve_runtime_ids) { + TestEngine &engine = testEngine(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + auto git = Gff::Builder() + .field(Gff::Field::newList( + "Creature List", {objectWithItem(2, 3)})) + .build(); + + game.reserveSavedObjectIds( + *git, + SerializedIdentityContext::detachedRecord("availnpc0.utc"), + SerializedGraphRoot::AreaGit); + EXPECT_EQ(game.newItem()->id(), 2u); +} + TEST(SaveGffShadow, deep_ownership_merge_and_authoritative_list_rebuild) { SaveGffShadow shadow; { @@ -123,7 +356,9 @@ TEST(SaveGffShadow, new_surviving_deleted_and_resource_retirement_semantics) { .field(Gff::Field::newDword("ObjectId", 90)) .field(Gff::Field::newInt("FutureU", 44)) .build(); - loaded->captureSaveRecord(*saved); + loaded->captureSaveRecord( + *saved, + SerializedIdentityContext::moduleGraph("test-module")); auto fresh = std::make_shared(91, game, engine.services()); EXPECT_TRUE(loaded->saveRecordProvenance()); @@ -156,10 +391,15 @@ TEST(ItemSaveProvenance, owner_local_id_is_a_hint_not_runtime_identity) { .field(Gff::Field::newDword("ObjectId", 700)) .field(Gff::Field::newInt("UpgradeFuture", 9)) .build(); - item->captureOwnerLocalSaveRecord( - *saved, {SaveRecordOriginKind::ContainedItem, "owner-a"}); + const auto identityContext = + SerializedIdentityContext::detachedRecord("owner-a.utc"); + item->captureSaveRecord( + *saved, identityContext, + {SaveRecordOriginKind::ContainedItem, "owner-a"}); - EXPECT_EQ(item->originalOwnerLocalObjectId(), std::optional(700)); + EXPECT_EQ( + item->serializedObjectIdentity(), + std::optional({identityContext, 700})); EXPECT_EQ(game.getObjectById(runtimeId), item); EXPECT_FALSE(game.getObjectById(700)); EXPECT_EQ(game.newItem()->id(), runtimeId + 1); @@ -170,11 +410,13 @@ TEST(ItemSaveProvenance, owner_local_id_is_a_hint_not_runtime_identity) { bool last = false; EXPECT_TRUE(ownerA->removeItem(item, last)); ownerB->addItem(item); - EXPECT_EQ(item->originalOwnerLocalObjectId(), std::optional(700)); + EXPECT_EQ( + item->serializedObjectIdentity(), + std::optional({identityContext, 700})); EXPECT_NE(item->id(), 700u); auto fresh = game.newItem(); - EXPECT_FALSE(fresh->originalOwnerLocalObjectId()); + EXPECT_FALSE(fresh->serializedObjectIdentity()); EXPECT_FALSE(fresh->saveRecordProvenance()); } @@ -198,9 +440,11 @@ TEST(EffectSaveProvenance, loaded_and_runtime_expiry_follow_live_collection) { TestEngine &engine = testEngine(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); - auto object = std::make_shared(2, game, engine.services()); + auto object = game.newObject(game, engine.services()); - auto loaded = EffectInstance::fromGff(*savedEffect()); + auto loaded = EffectInstance::fromGff( + *savedEffect(), + SerializedIdentityContext::moduleGraph("test-module")); EXPECT_EQ(loaded.expiryOrigin, EffectExpiryOrigin::LoadedAbsoluteGameTime); ASSERT_TRUE(object->restoreEffect(loaded)); ASSERT_EQ(object->saveEffectSnapshot().size(), 1); @@ -226,9 +470,11 @@ TEST(ActionSaveProvenance, pending_loaded_cancelled_completed_and_new_waits) { TestEngine &engine = testEngine(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); - auto object = std::make_shared(2, game, engine.services()); + auto object = game.newObject(game, engine.services()); - SavedActionRecord loaded = SavedActionRecord::fromGff(*savedWait()); + SavedActionRecord loaded = SavedActionRecord::fromGff( + *savedWait(), + SerializedIdentityContext::moduleGraph("test-module")); auto wait = loaded.toRuntimeAction(game); ASSERT_TRUE(wait); EXPECT_TRUE(wait->originalSavedAction()); @@ -259,7 +505,7 @@ TEST(ActionSaveProvenance, play_animation_uses_retail_id_shape_and_live_state) { TestEngine &engine = testEngine(); StubConsole console; Game game(GameID::TSL, "", engine.options(), engine.services(), console); - auto object = std::make_shared(2, game, engine.services()); + auto object = game.newObject(game, engine.services()); auto pending = game.newAction( AnimationType::LoopingPause, 1.25f, 10.0f); @@ -322,7 +568,7 @@ TEST(ActionSaveProvenance, play_animation_import_is_inert_and_semantically_symme EXPECT_FLOAT_EQ(std::get(exported->parameters[2].payload), 6.0f); EXPECT_EQ(std::get(exported->parameters[3].payload), 0); - auto object = std::make_shared(2, game, engine.services()); + auto object = game.newObject(game, engine.services()); object->addAction(runtime); object->addAction(game.newAction(2.0f)); object->tickActionQueue(5.0f); @@ -338,7 +584,7 @@ TEST(ActionSaveProvenance, unsupported_loaded_record_is_live_until_queue_clear) TestEngine &engine = testEngine(); StubConsole console; Game game(GameID::KotOR, "", engine.options(), engine.services(), console); - auto object = std::make_shared(2, game, engine.services()); + auto object = game.newObject(game, engine.services()); auto unsupported = Gff::Builder() .field(Gff::Field::newDword("ActionId", 0xdead)) .field(Gff::Field::newWord("NumParams", 0)) @@ -347,7 +593,9 @@ TEST(ActionSaveProvenance, unsupported_loaded_record_is_live_until_queue_clear) .field(Gff::Field::newList( "ActionList", {savedWait(2.0f), unsupported})) .build(); - object->deserializeRuntimeState(*root); + object->deserializeRuntimeState( + *root, + SerializedIdentityContext::moduleGraph("test-module")); object->bindSavedRuntimeState(); object->publishSavedRuntimeState(); auto snapshot = object->saveActionSnapshot(); @@ -365,12 +613,17 @@ TEST(EventSaveProvenance, loaded_delivery_cancellation_new_and_session_replaceme .WillByDefault(ReturnRef(sceneGraph)); Game game(GameID::KotOR, "", engine.options(), engine.services(), console); auto clock = Gff::Builder() - .field(Gff::Field::newDword("Mod_CalendarDay", 4)) - .field(Gff::Field::newDword("Mod_TimeOfDay", 100)) + .field(Gff::Field::newDword("Mod_PauseDay", 4)) + .field(Gff::Field::newDword("Mod_PauseTime", 100)) .field(Gff::Field::newDword("Mod_MinPerHour", 5)) .build(); - game.prepareSavedRuntimeNamespace(*clock); + game.prepareSavedRuntimeNamespace( + *clock, SerializedIdentityContext::moduleGraph("test-module")); auto target = game.newCreature(); + game.registerSavedObjectIdentity( + target->id(), + target, + SerializedIdentityContext::moduleGraph("test-module")); auto moduleA = game.newModule(); auto queue = Gff::Builder() .field(Gff::Field::newList( @@ -383,7 +636,9 @@ TEST(EventSaveProvenance, loaded_delivery_cancellation_new_and_session_replaceme static_cast(SavedEventType::DestroyObject), target->id())})) .build(); - moduleA->deserializeSavedEventQueue(*queue); + moduleA->deserializeSavedEventQueue( + *queue, + SerializedIdentityContext::moduleGraph("test-module")); moduleA->bindSavedEventQueue(); moduleA->publishSavedEventQueue(); ASSERT_EQ(moduleA->saveEventSnapshot().size(), 2); diff --git a/test/game/savewidesnapshot.cpp b/test/game/savewidesnapshot.cpp index a1ac45686..faae24e91 100644 --- a/test/game/savewidesnapshot.cpp +++ b/test/game/savewidesnapshot.cpp @@ -159,7 +159,9 @@ RichState configureRich(Game &game, bool tsl) { .field(Gff::Field::newShort("CurrentHitPoints", 1)) .build(); result.npc->captureSaveRecord( - *npcShadow, {SaveRecordOriginKind::AvailableNpc, "0"}); + *npcShadow, + SerializedIdentityContext::detachedRecord("availnpc0.utc"), + {SaveRecordOriginKind::AvailableNpc, "0"}); result.npc->setCurrentHitPoints(19); game.party().addAvailableMember(0, result.npc); game.party().clear(); @@ -173,7 +175,9 @@ RichState configureRich(Game &game, bool tsl) { "FuturePuppet", "puppet-shadow")) .build(); result.puppet->captureSaveRecord( - *puppetShadow, {SaveRecordOriginKind::AvailablePuppet, "0"}); + *puppetShadow, + SerializedIdentityContext::detachedRecord("availpup0.utc"), + {SaveRecordOriginKind::AvailablePuppet, "0"}); game.party().addAvailablePuppet(0, result.puppet); } @@ -187,8 +191,10 @@ RichState configureRich(Game &game, bool tsl) { "FutureItem", "item-shadow")) .build(); result.item->setStackSize(3); - result.item->captureOwnerLocalSaveRecord( - *itemShadow, {SaveRecordOriginKind::PartyInventoryItem, "inventory"}); + result.item->captureSaveRecord( + *itemShadow, + SerializedIdentityContext::detachedRecord("inventory.res"), + {SaveRecordOriginKind::PartyInventoryItem, "inventory"}); result.player->addItem(result.item); game.journal().restoreEntry("tat17_landing", 30, 4, 500); @@ -256,6 +262,62 @@ void configureReputes(TestEngine &engine) { .WillRepeatedly(Return(factionState())); } +std::shared_ptr freshNewGamePartyTable(GameID gameId) { + auto &engine = testEngine(); + configureReputes(engine); + StubConsole console; + Game game(gameId, "", engine.options(), engine.services(), console); + + // Storage reset is deliberately not new-game initialization. This is the + // same final reset/install order used by CharacterGeneration::finish(). + game.party().reset(); + EXPECT_FALSE(game.party().hasValidPazaakData()); + game.party().initializeNewGameState(); + + auto area = game.newArea(); + auto player = game.newCreature(); + TestGameModule::configureModuleSnapshot( + game, + area, + player, + gameId == GameID::TSL ? "001ebo" : "end_m01aa", + gameId == GameID::TSL ? "001ebo" : "end_m01aa"); + + auto saved = SaveWideSnapshotBuilder( + game, metadata(gameId == GameID::TSL)) + .build(); + EXPECT_TRUE(saved) << saved.message; + if (!saved) return nullptr; + return readGff( + saved.snapshot->looseSlotResources.at({"partytable", ResType::Res})); +} + +std::shared_ptr savedPazaakTable( + size_t cardCount, + int firstCardCount, + int ninthCardCount) { + std::vector> cards; + for (size_t card = 0; card < cardCount; ++card) { + int count = card == 0 ? firstCardCount + : card == 8 ? ninthCardCount : 0; + cards.push_back( + Gff::Builder() + .field(Gff::Field::newByte("PT_PAZAAKCOUNT", count)) + .build()); + } + std::vector> sideDeck; + for (size_t slot = 0; slot < Party::kK1PazaakSideDeckSize; ++slot) { + sideDeck.push_back( + Gff::Builder() + .field(Gff::Field::newInt("PT_PAZSIDECARD", -1)) + .build()); + } + return Gff::Builder() + .field(Gff::Field::newList("PT_PAZAAKCARDS", std::move(cards))) + .field(Gff::Field::newList("PT_PAZSIDELIST", std::move(sideDeck))) + .build(); +} + struct TempArchive { std::filesystem::path path; @@ -282,6 +344,85 @@ struct TempArchive { } // namespace +TEST(SaveWideSnapshot, party_reset_remains_distinct_from_new_game_initialization) { + auto &engine = testEngine(); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + + game.party().initializeNewGameState(); + ASSERT_TRUE(game.party().hasValidPazaakData()); + game.party().reset(); + + EXPECT_FALSE(game.party().hasValidPazaakData()); +} + +TEST(SaveWideSnapshot, fresh_k1_party_has_saveable_title_defaults) { + auto party = freshNewGamePartyTable(GameID::KotOR); + + ASSERT_TRUE(party); + const auto &cards = party->getList("PT_PAZAAKCARDS"); + const auto &sideDeck = party->getList("PT_PAZSIDELIST"); + ASSERT_EQ(Party::kK1PazaakCardCount, cards.size()); + ASSERT_EQ(Party::kK1PazaakSideDeckSize, sideDeck.size()); + for (size_t card = 0; card < cards.size(); ++card) { + EXPECT_EQ(card < 5 ? 2 : 0, cards[card]->getInt("PT_PAZAAKCOUNT")); + } + for (const auto &slot : sideDeck) { + EXPECT_EQ(-1, slot->getInt("PT_PAZSIDECARD")); + } +} + +TEST(SaveWideSnapshot, fresh_k2_party_has_saveable_title_defaults) { + auto party = freshNewGamePartyTable(GameID::TSL); + + ASSERT_TRUE(party); + const auto &cards = party->getList("PT_PAZAAKCARDS"); + const auto &sideDeck = party->getList("PT_PAZSIDELIST"); + ASSERT_EQ(Party::kK2PazaakCardCount, cards.size()); + ASSERT_EQ(Party::kK1PazaakSideDeckSize, sideDeck.size()); + for (size_t card = 0; card < cards.size(); ++card) { + EXPECT_EQ(card < 5 ? 2 : 0, cards[card]->getInt("PT_PAZAAKCOUNT")); + } + for (const auto &slot : sideDeck) { + EXPECT_EQ(-1, slot->getInt("PT_PAZSIDECARD")); + } +} + +TEST(SaveWideSnapshot, restored_pazaak_state_remains_authoritative) { + auto &engine = testEngine(); + configureReputes(engine); + StubConsole console; + Game game(GameID::KotOR, "", engine.options(), engine.services(), console); + auto party = savedPazaakTable(Party::kK1PazaakCardCount, 7, 3); + + game.party().reset(); + TestGameModule::deserializePartyTable(game, *party); + ASSERT_TRUE(game.party().hasValidPazaakData()); + EXPECT_EQ(7, game.party().pazaakCardCounts()[0]); + EXPECT_EQ(3, game.party().pazaakCardCounts()[8]); + + auto area = game.newArea(); + auto player = game.newCreature(); + TestGameModule::configureModuleSnapshot( + game, area, player, "end_m01aa", "end_m01aa"); + auto saved = SaveWideSnapshotBuilder(game, metadata(false)).build(); + + ASSERT_TRUE(saved) << saved.message; + auto roundTripped = readGff( + saved.snapshot->looseSlotResources.at({"partytable", ResType::Res})); + ASSERT_EQ( + Party::kK1PazaakCardCount, + roundTripped->getList("PT_PAZAAKCARDS").size()); + EXPECT_EQ( + 7, + roundTripped->getList("PT_PAZAAKCARDS")[0]->getInt( + "PT_PAZAAKCOUNT")); + EXPECT_EQ( + 3, + roundTripped->getList("PT_PAZAAKCARDS")[8]->getInt( + "PT_PAZAAKCOUNT")); +} + TEST(SaveWideSnapshot, rich_k1_round_trips_all_common_state_and_shadows) { auto &engine = testEngine(); configureReputes(engine); @@ -428,11 +569,15 @@ TEST(SaveWideSnapshot, thirteen_item_inventory_round_trip_is_singular_and_semant .field(Gff::Field::newWord("StackSize", index + 1)) .field(Gff::Field::newCExoString("Tag", "roundtrip_" + std::to_string(index))) .build(); - item->deserializeRuntimeState(*record); + item->deserializeRuntimeState( + *record, + SerializedIdentityContext::detachedRecord("inventory.res")); item->setTag("roundtrip_" + std::to_string(index)); item->setStackSize(index + 1); - item->captureOwnerLocalSaveRecord( - *record, {SaveRecordOriginKind::PartyInventoryItem, "inventory"}); + item->captureSaveRecord( + *record, + SerializedIdentityContext::detachedRecord("inventory.res"), + {SaveRecordOriginKind::PartyInventoryItem, "inventory"}); rich.player->addItem(item); expectedStacks.push_back(index + 1); } @@ -534,8 +679,6 @@ TEST(SaveWideSnapshot, rich_k2_writes_title_specific_party_pc_puppet_and_nfo) { loadedPc->setMaxHitPoints(pc->getInt("MaxHitPoints")); loadedPc->setCurrentHitPoints(pc->getInt("CurrentHitPoints")); EXPECT_EQ(loadedPc->currentHitPoints(), 17); - loadedPc->restorePrimaryPlayerHitPoints(); - EXPECT_EQ(loadedPc->currentHitPoints(), loadedPc->maxHitPoints()); auto puppet = readGff(saved.snapshot->outerWorkingResources.at({"availpup0", ResType::Utc})); EXPECT_EQ(puppet->getString("FuturePuppet"), "puppet-shadow"); @@ -560,17 +703,19 @@ TEST(SaveWideSnapshot, auto area = game.newArea(); auto controlled = game.newCreature(); controlled->setName("T3-M4"); - TestGameModule::setSnapshotObjectId(*controlled, 330); - controlled->setTag(kObjectTagPlayer); + controlled->setTag("t3m4"); controlled->setMaxHitPoints(30); controlled->setCurrentHitPoints(24); auto controlledShadow = Gff::Builder() .type(0xffffffff) + .field(Gff::Field::newDword("ObjectId", 330)) .field(Gff::Field::newCExoLocString("FirstName", -1, "T3-M4")) .build(); controlled->captureSaveRecord( - *controlledShadow, {SaveRecordOriginKind::ModulePlayer, {}}); + *controlledShadow, + SerializedIdentityContext::moduleGraph("106per"), + {SaveRecordOriginKind::ModulePlayer, {}}); TestGameModule::configureModuleSnapshot( game, area, controlled, "106per", "106per"); @@ -585,7 +730,9 @@ TEST(SaveWideSnapshot, .field(Gff::Field::newCExoLocString("FirstName", -1, "Ta'ahn Kaast")) .build(); canonical->captureSaveRecord( - *canonicalShadow, {SaveRecordOriginKind::PrimaryPlayerUtc, {}}); + *canonicalShadow, + SerializedIdentityContext::detachedRecord("pc.utc"), + {SaveRecordOriginKind::PrimaryPlayerUtc, {}}); Party::PersistedState state; state.pcName = "Ta'ahn Kaast"; @@ -598,11 +745,12 @@ TEST(SaveWideSnapshot, Party::PazaakSideDeck sideDeck; sideDeck.fill(-1); game.party().setPazaakData(cards, sideDeck, Party::kK2PazaakCardCount); - game.party().addAvailableMember(8, controlled); + game.party().setActualPlayer(canonical); + ASSERT_TRUE(game.party().addAvailableMember(8, controlled)); game.party().clear(); - game.party().addMember(8, controlled); + ASSERT_TRUE(game.party().addMember(8, controlled)); game.party().setPlayer(controlled); - game.party().setActualPlayer(canonical); + ASSERT_EQ(controlled, game.party().getAvailableMember(8)); auto module = ModuleSnapshotBuilder(game, "game13").build(); auto saved = SaveWideSnapshotBuilder(game, metadata(true)).build(); @@ -620,7 +768,7 @@ TEST(SaveWideSnapshot, EXPECT_EQ("Ta'ahn Kaast", pc->getString("FirstName")); EXPECT_FALSE(pc->has("ObjectId")); ASSERT_EQ(1u, module.snapshot->ifo->getList("Mod_PlayerList").size()); - EXPECT_EQ(kObjectTagPlayer, + EXPECT_EQ("t3m4", module.snapshot->ifo->getList("Mod_PlayerList")[0]->getString("Tag")); Game reloaded(GameID::TSL, "", engine.options(), engine.services(), console); @@ -639,9 +787,7 @@ TEST(SaveWideSnapshot, EXPECT_CALL( engine.resourceModule().director(), findSaveWorking(ResourceId("availnpc8", ResType::Utc))) - .WillOnce(Return(Resource {ByteBuffer( - saved.snapshot->outerWorkingResources.at( - {"availnpc8", ResType::Utc}))})); + .Times(0); auto reloadedIfo = readGff(module.snapshot->ifoBytes); TestGameModule::publishPartyRuntimeState( reloaded, *reloadedIfo, party, pc); @@ -649,7 +795,7 @@ TEST(SaveWideSnapshot, ASSERT_TRUE(reloaded.party().player()); ASSERT_TRUE(reloaded.party().actualPlayer()); EXPECT_NE(reloaded.party().player(), reloaded.party().actualPlayer()); - EXPECT_EQ(kObjectTagPlayer, reloaded.party().player()->tag()); + EXPECT_EQ("t3m4", reloaded.party().player()->tag()); EXPECT_EQ("T3-M4", reloaded.party().player()->name()); EXPECT_EQ("canonical_pc", reloaded.party().actualPlayer()->tag()); EXPECT_EQ("Ta'ahn Kaast", reloaded.party().actualPlayer()->name()); @@ -674,7 +820,7 @@ TEST(SaveWideSnapshot, EXPECT_TRUE(rebuiltParty->getList("PT_MEMBERS").empty()); EXPECT_EQ("Ta'ahn Kaast", rebuiltPc->getString("FirstName")); ASSERT_EQ(1u, rebuiltModule.snapshot->ifo->getList("Mod_PlayerList").size()); - EXPECT_EQ(kObjectTagPlayer, + EXPECT_EQ("t3m4", rebuiltModule.snapshot->ifo->getList("Mod_PlayerList")[0]->getString("Tag")); } diff --git a/test/game/worldtime.cpp b/test/game/worldtime.cpp index a01786ed2..6647327a3 100644 --- a/test/game/worldtime.cpp +++ b/test/game/worldtime.cpp @@ -32,7 +32,16 @@ void reone::game::TestGameModule::advanceWorldTime(Game &game, float dt) { } void reone::game::TestGameModule::prepareWorldTimeFromIfo(Game &game, const Gff &ifo) { - game.prepareSavedRuntimeNamespace(ifo); + game.prepareSavedRuntimeNamespace( + ifo, SerializedIdentityContext::moduleGraph("test-module")); +} + +void reone::game::TestGameModule::restoreAutosaveWorldTime( + Game &game, + const Gff &moduleIfo, + uint32_t pauseDay, + uint32_t pauseTime) { + game.restoreWorldTime(moduleIfo, pauseDay, pauseTime); } namespace { @@ -202,10 +211,10 @@ TEST_P(WorldTimeFixture, temporary_effect_expiry_round_trips_as_a_real_duration) } } -TEST_P(WorldTimeFixture, composes_the_canonical_clock_from_the_saved_calendar_pair) { +TEST_P(WorldTimeFixture, composes_the_canonical_clock_from_the_retail_pause_pair) { auto ifo = Gff::Builder().type(0xffffffff) - .field(Gff::Field::newDword("Mod_CalendarDay", 3)) - .field(Gff::Field::newDword("Mod_TimeOfDay", 1234u)) + .field(Gff::Field::newDword("Mod_PauseDay", 3)) + .field(Gff::Field::newDword("Mod_PauseTime", 1234u)) .field(Gff::Field::newDword("Mod_MinPerHour", 2)) .build(); @@ -224,8 +233,8 @@ TEST_P(WorldTimeFixture, out_of_range_saved_time_of_day_carries_into_the_calenda // CWorldTimer::GetWorldTime does, rather than rejecting the save. constexpr uint32_t kLegacyTimeOfDay = 24u * 60u * 60u * 1000u - 1u; auto ifo = Gff::Builder().type(0xffffffff) - .field(Gff::Field::newDword("Mod_CalendarDay", 3)) - .field(Gff::Field::newDword("Mod_TimeOfDay", kLegacyTimeOfDay)) + .field(Gff::Field::newDword("Mod_PauseDay", 3)) + .field(Gff::Field::newDword("Mod_PauseTime", kLegacyTimeOfDay)) .field(Gff::Field::newDword("Mod_MinPerHour", 2)) .build(); @@ -249,8 +258,8 @@ TEST_P(WorldTimeFixture, calendar_pair_round_trips_across_a_day_boundary) { const uint64_t before = game.worldTimeMilliseconds(); auto ifo = Gff::Builder().type(0xffffffff) - .field(Gff::Field::newDword("Mod_CalendarDay", game.worldTimeDay())) - .field(Gff::Field::newDword("Mod_TimeOfDay", game.worldTimeOfDay())) + .field(Gff::Field::newDword("Mod_PauseDay", game.worldTimeDay())) + .field(Gff::Field::newDword("Mod_PauseTime", game.worldTimeOfDay())) .field(Gff::Field::newDword("Mod_MinPerHour", game.minutesPerHour())) .build(); @@ -259,6 +268,40 @@ TEST_P(WorldTimeFixture, calendar_pair_round_trips_across_a_day_boundary) { << "splitting on save and composing on load must be lossless"; } +TEST_P(WorldTimeFixture, reads_legacy_reone_clock_only_when_retail_fields_are_absent) { + auto legacy = Gff::Builder().type(0xffffffff) + .field(Gff::Field::newDword("Mod_CalendarDay", 3)) + .field(Gff::Field::newDword("Mod_TimeOfDay", 1234u)) + .field(Gff::Field::newDword("Mod_MinPerHour", 2)) + .build(); + ASSERT_NO_THROW(TestGameModule::prepareWorldTimeFromIfo(game, *legacy)); + EXPECT_EQ(game.worldTimeMilliseconds(), + 3ull * (2u * 60u * 1000u * 24u) + 1234ull); + + auto retail = Gff::Builder().type(0xffffffff) + .field(Gff::Field::newDword("Mod_PauseDay", 4)) + .field(Gff::Field::newDword("Mod_PauseTime", 5678u)) + .field(Gff::Field::newDword("Mod_CalendarDay", 99)) + .field(Gff::Field::newDword("Mod_TimeOfDay", 99u)) + .field(Gff::Field::newDword("Mod_MinPerHour", 2)) + .build(); + ASSERT_NO_THROW(TestGameModule::prepareWorldTimeFromIfo(game, *retail)); + EXPECT_EQ(game.worldTimeMilliseconds(), + 4ull * (2u * 60u * 1000u * 24u) + 5678ull); +} + +TEST_P(WorldTimeFixture, template_autosave_uses_pause_time_from_autosave_params) { + auto installedIfo = Gff::Builder().type(0xffffffff) + .field(Gff::Field::newDword( + "Mod_MinPerHour", 2)) + .build(); + TestGameModule::restoreAutosaveWorldTime( + game, *installedIfo, 7, 4321); + EXPECT_EQ(game.minutesPerHour(), 2); + EXPECT_EQ(game.worldTimeMilliseconds(), + 7ull * (2u * 60u * 1000u * 24u) + 4321ull); +} + INSTANTIATE_TEST_SUITE_P( BothGames, WorldTimeFixture, diff --git a/test/resource/moduleloading.cpp b/test/resource/moduleloading.cpp index af2830772..23cdb6ed7 100644 --- a/test/resource/moduleloading.cpp +++ b/test/resource/moduleloading.cpp @@ -39,7 +39,9 @@ #include "reone/resource/director.h" #include "reone/resource/exception/notfound.h" #include "reone/resource/extractresources.h" +#include "reone/resource/gff.h" #include "reone/resource/format/erfwriter.h" +#include "reone/resource/format/gffwriter.h" #include "reone/resource/format/rimwriter.h" #include "reone/resource/resources.h" #include "reone/system/stream/fileoutput.h" @@ -62,12 +64,39 @@ ByteBuffer bytes(std::string_view value) { return ByteBuffer(value.begin(), value.end()); } +std::string gffBlob(std::string_view signature, const Gff &gff) { + auto encoded = GffWriter( + GffFileFormat::v32(std::string(signature)), gff) + .toBytes(); + return std::string(encoded.begin(), encoded.end()); +} + struct NamedRes { std::string resRef; ResType type; std::string data; }; +std::vector structuralModule( + const std::string &area, + std::string marker = {}) { + auto ifo = Gff::Builder() + .field(Gff::Field::newResRef("Mod_Entry_Area", area)) + .build(); + auto are = Gff::Builder().build(); + auto git = Gff::Builder().build(); + std::vector result { + {"module", ResType::Ifo, gffBlob("IFO ", *ifo)}, + {area, ResType::Are, gffBlob("ARE ", *are)}, + {area, ResType::Git, gffBlob("GIT ", *git)}, + {area, ResType::Lyt, + "beginlayout\nroomcount 0\ntrackcount 0\nobstaclecount 0\ndonelayout\n"}}; + if (!marker.empty()) { + result.push_back({marker, ResType::Txt, marker}); + } + return result; +} + ByteBuffer erfBytes(ErfWriter::FileType fileType, const std::vector &resources) { ErfWriter writer; for (const auto &res : resources) { @@ -1154,6 +1183,70 @@ TEST(ResourceDirectorActivation, places_both_games_in_the_raw_lookup_order) { EXPECT_TRUE(usesBucketedLookup(GameID::KotOR)); } +TEST_P(K2ModuleLoadingTest, prepared_module_is_private_until_exact_plan_commit) { + TmpDir game("reone_test_prepared_module_private"); + TmpDir cwd("reone_test_prepared_module_private_cwd"); + makeInstallation(game, cwd); + + auto modules = game.path / "modules"; + writeRim(modules / "old.rim", structuralModule("oldarea", "old_marker")); + writeRim(modules / "next.rim", structuralModule("nextarea", "next_marker")); + + auto director = makeDirector(game.path); + { + CwdGuard guard(cwd.path); + director->init(); + } + director->onModuleLoad("old"); + ASSERT_EQ("old_marker", find("old_marker")); + ASSERT_FALSE(has("next_marker")); + + auto prepared = director->prepareModuleLoad("next", nullptr); + + ASSERT_TRUE(prepared); + EXPECT_TRUE(prepared->structurallyValidated()); + EXPECT_EQ("next", prepared->moduleName()); + EXPECT_EQ("nextarea", prepared->moduleIfo()->getString("Mod_Entry_Area")); + EXPECT_EQ("old_marker", find("old_marker")); + EXPECT_FALSE(has("next_marker")); + + director->commitModuleLoad(std::move(prepared)); + + EXPECT_FALSE(has("old_marker")); + EXPECT_EQ("next_marker", find("next_marker")); +} + +TEST_P(K2ModuleLoadingTest, rejected_module_structure_leaves_active_mounts_untouched) { + TmpDir game("reone_test_prepared_module_reject"); + TmpDir cwd("reone_test_prepared_module_reject_cwd"); + makeInstallation(game, cwd); + + auto modules = game.path / "modules"; + writeRim(modules / "old.rim", structuralModule("oldarea", "old_marker")); + auto malformed = structuralModule("brokenarea", "broken_marker"); + malformed.erase( + std::remove_if( + malformed.begin(), malformed.end(), + [](const NamedRes &resource) { + return resource.type == ResType::Git; + }), + malformed.end()); + writeRim(modules / "broken.rim", malformed); + + auto director = makeDirector(game.path); + { + CwdGuard guard(cwd.path); + director->init(); + } + director->onModuleLoad("old"); + + EXPECT_THROW( + (void)director->prepareModuleLoad("broken", nullptr), + ResourceNotFoundException); + EXPECT_EQ("old_marker", find("old_marker")); + EXPECT_FALSE(has("broken_marker")); +} + INSTANTIATE_TEST_SUITE_P(Backends, K2ModuleLoadingTest, testing::Values(Backend::Legacy, Backend::Extract), diff --git a/test/resource/sourcelifetimes.cpp b/test/resource/sourcelifetimes.cpp index bebbbe17b..a33286ade 100644 --- a/test/resource/sourcelifetimes.cpp +++ b/test/resource/sourcelifetimes.cpp @@ -428,6 +428,11 @@ class RejectingResources : public IResources { Resource get(const ResourceId &id) override { return _backend->get(id); } std::optional find(const ResourceId &id) override { return _backend->find(id); } + std::optional findExcludingOwners( + const ResourceId &id, + const std::set &excludedOwners) override { + return _backend->findExcludingOwners(id, excludedOwners); + } private: void reject(const std::filesystem::path &path) const {