NPC Home-Location Data Spike: Real, Statically Readable, and Not Worth Building Yet¶
Document File ID: docs/research/32-npc-home-location-data-spike.md
TL;DR¶
Skyrim's AI-package system genuinely does carry per-NPC home/workplace
location data, and a real, from-scratch Mutagen program in this pass
successfully read it directly out of the real Skyrim.esm/
HearthFires.esm data for 2 of the 3 requested named-cast NPCs: Amren's
and Braith's own WhiterunAmrenHomePackage/WhiterunBraithHomePackage
records each carry a PackageDataLocation subrecord whose target is a
plain LocationCell FormLink resolving to the real WhiterunAmrensHouse
cell record -- a fully static, no-game-logic read. Ysolda's case is the
honest complication: she has no explicit "home package" at all, only a
DefaultSandboxHomeowner package using the LocationFallback/
"near editor location" indirection, which per CommonLibSSE-NG's own
BGSLocation::uniqueNPCs (LCUN) field is resolved via a separate
reverse-index (which Location record claims this NPC as a unique actor)
rather than a direct link -- a real, header-documented mechanism, but one
this pass's own tooling hit real friction reproducing statically (see F4).
Meanwhile, EvidencePoller.cpp is not hypothetical -- it is already
shipped, already spawning evidence objects at an NPC's live
GetPosition(), working identically for all 19 named-cast NPCs regardless
of whether they have a resolvable home package. Verdict: the underlying
data is [BUILD-ON]-tier where it exists at all (a real static Mutagen
read, no reverse-engineering), but authoring/tracking a real home-location
model for Chronicle is NOT WORTH PURSUING right now -- the coverage is
uneven (2/3 sampled NPCs have a clean answer, 1/3 doesn't), the payoff
duplicates what EvidencePoller.cpp's live-position fallback already does
uniformly, and no roadmap item currently needs "the scene of a specific
incident" as opposed to "near this NPC."
Findings¶
[F1] [VERIFIED] TESNPC itself has zero home/location fields --
confirmed by reading the real header a second time, at every candidate
name. RE/T/TESNPC.h's member list (skills, class, combat style, race,
outfits, faction, head parts, tint, relationships -- every field from
offset 0x190 to 0x268) contains nothing resembling a home marker, a
workplace cell, or a location FormLink. RE/T/TESPackage.h's
PACKAGE_DATA (AIDT-equivalent read via Mutagen's own AIData
subrecord, independently confirmed identical: Aggression, Confidence,
EnergyLevel, Responsibility, Mood, Assistance, Warn,
WarnOrAttack, Attack -- no location field either) closes off the other
obvious candidate. Any home/workplace concept in Skyrim lives on the AI
package system, never on the NPC or its AI-data block directly -- this
sharpens report 31's F5, which only checked IdentityMap.cpp/
chronicle/claims.py and correctly found nothing there, but didn't yet
check whether the engine has the concept anywhere to read from.
[F2] [VERIFIED] TESPackage::packLoc (RE::PackageLocation) is a real,
documented, non-virtual field carrying exactly the location-type
enumeration a home/workplace read would need, and Mutagen's own schema
mirrors it field-for-field:
// RE/T/TESPackage.h
PackageLocation* packLoc; // 38
// RE/P/PackageLocation.h
enum class Type {
kNone = -1, kNearReference = 0, kInCell = 1, kNearPackageStartLocation = 2,
kNearEditorLocation = 3, kObjectID = 4, kObjectType = 5,
kNearLinkedReference = 6, kAtPackagelocation = 7, kAlias_Reference = 8,
kAlias_Location = 9, kNearSelf = 12,
};
union Data { TESForm* object; ObjectRefHandle refHandle; };
<!-- Mutagen: Common Subrecords/LocationTargetRadius.xml -->
<Object name="LocationTargetRadius" objType="Subrecord">
<Fields>
<RefDirect name="Target" refName="ALocationTarget" .../>
<UInt32 name="Radius" />
</Fields>
</Object>
<Object name="LocationCell" baseClass="ALocationTarget" objType="Subrecord">
<Fields><FormLink name="Link" refName="Cell" /></Fields>
</Object>
<Object name="LocationTarget" baseClass="ALocationTarget" objType="Subrecord">
<Fields><FormLink name="Link"><Interface>IPlaced</Interface></FormLink></Fields>
</Object>
<Object name="LocationFallback" baseClass="ALocationTarget" objType="Subrecord">
<Fields>
<Enum name="Type" enumName="LocationTargetRadius.LocationType" byteLength="4" />
<Int32 name="Data" />
</Fields>
</Object>
RE::'s kInCell/kNearReference/kNearEditorLocation map 1:1 onto
Mutagen's LocationCell/LocationTarget/LocationFallback variants --
the two independently-sourced schemas (a C++ header checkout and a C#
Loqui schema, maintained by different open-source projects) agree exactly,
the same cross-confirmation pattern report 31's F1-F4 used.
[F3] [VERIFIED, real-data proof-of-concept] Two of the three sampled
named-cast NPCs have a plain, statically-readable home-location package --
no game logic, no reverse-engineering, a direct field read. A from-
scratch Mutagen console app (built against the exact
Mutagen.Bethesda.Skyrim 23.4.0 package tools/chronicle-patcher/ itself
pins) loaded the real Skyrim.esm+HearthFires.esm from
~/Games/ChronicleDev/Stock Game/Data and printed every package on
Amren's, Braith's, and Ysolda's real NPC records. Amren's real output:
[Packages] Package WhiterunAmrenHomePackage (02C901:Skyrim.esm) type=Package schedHour=19
-> LOCATION target type=LocationCell radius=0
Cell: WhiterunAmrensHouse (0165AB:Skyrim.esm)
Braith's real output (she shares the same house):
[Packages] Package WhiterunBraithHomePackage (02C903:Skyrim.esm) type=Package schedHour=-1
-> LOCATION target type=LocationCell radius=0
Cell: WhiterunAmrensHouse (0165AB:Skyrim.esm)
[Packages] Package WhiterunBraithSleep21x12 (10F979:Skyrim.esm) type=Package schedHour=21
-> LOCATION target type=LocationCell radius=0
Cell: WhiterunAmrensHouse (0165AB:Skyrim.esm)
This is a real CELL FormKey (0165AB:Skyrim.esm), resolvable the exact
same LinkCache.TryResolve way tools/chronicle-patcher/ already
resolves other records -- editor-ID-named, human-legible
(WhiterunAmrensHouse), and requires zero AI-package procedural logic to
interpret: the package's own name (...HomePackage) and schedule hour
(19, 21 -- evening/night) corroborate that this is what a person would
call "home" without needing any further inference.
[F4] [NEW FRICTION, not a blocker but a real limit] Not every NPC has an
explicit home package -- some resolve "home" through a second-order
reverse-index this pass could not fully reproduce with the pinned Mutagen
version. Ysolda's real package list has no ...HomePackage entry at
all:
[Packages] Package WhiterunYsoldaMarket8x7 schedHour=8 -> LocationTarget (a market stall ref)
[Packages] Package WhiterunYsoldaCarryBasketPatrol16x4 schedHour=16 -> TARGET (no location)
[Packages] Package WhiterunYsoldaBanneredMarePackage20x4 schedHour=20 -> LocationTarget (the Bannered Mare)
[Packages] Package DefaultSleepEditorLoc24x8 schedHour=0 -> LocationFallback radius=2000
[Packages] Package DefaultSandboxHomeowner schedHour=-1 -> LocationFallback radius=1500
DefaultSandboxHomeowner/DefaultSleepEditorLoc24x8 use
kNearEditorLocation (Mutagen's LocationFallback), which per RE/B/
BGSLocation.h's own uniqueNPCs field (BSTArray<UniqueNPCData>, "LCUN")
resolves via a location-owns-the-NPC reverse index, not an NPC-owns-a-
location forward link -- Mutagen's own Location.xml confirms the same
shape exists in the file format (UniqueActorReferencesStatic/LCUN:
{Actor: Npc, Ref: PlacedNpc, Location: Location}). This pass tried to
read that reverse-index directly and hit a real, documented version
mismatch: the local mutagen-src git checkout used for schema reading
in this and report 31 is a newer Mutagen version than the
23.4.0 NuGet package this project's own ChroniclePatcher.csproj
actually pins and builds against (confirmed via System.Reflection against
the real installed DLL: the 23.4.0 assembly names the equivalent fields
ActorCellUniques/LocationCellUniques/ReferenceCellUnique, an
unrelated-looking, apparently auto-generated naming scheme, not the git
checkout's UniqueActorReferencesAdded/Static/Removed). Querying
LocationCellUniques in 23.4.0 against the real data returned zero
matches for Ysolda -- not proof the mechanism is fictional (the header/
schema evidence for it is solid, per F1-F2), but a real, first-hand
demonstration that resolving a "no explicit home package" NPC's location
costs meaningfully more engineering than F3's clean cases: version-
sensitive schema navigation, a second FormKey hop through whichever
placed-actor reference LCUN actually points at, and (per this pass's
inconclusive result) possibly game-only editor-time data that never made
it into any binary record Mutagen can read at all. This was not chased
further, per this task's own scope (spike, not implementation).
[F5] [VERIFIED] EvidencePoller.cpp's live-position proxy (report 31's
recommendation) is not a future plan -- it is already shipped, real code,
and covers all 19 named-cast NPCs uniformly regardless of F3/F4's uneven
static coverage. Read directly:
// EvidencePoller.cpp
RE::Actor* believer = ResolveLiveActor(entry.holderId);
...
auto spawned = believer->PlaceObjectAtMe(evidenceObject, true);
This resolves through IdentityMap.cpp's existing reverse lookup and
calls the same PlaceObjectAtMe report 31's F2 verified, using whatever
position the actor is actually standing at right now -- Ysolda at the
market, Amren at home, doesn't matter which. It needs zero location data
of any kind, works identically for the NPC with a clean home package
(Amren) and the one without (Ysolda), and needs no version-sensitive
Mutagen reverse-index work at all. The comment in the file candidly notes
this is "UNVERIFIED against a live save -- compiled only" (an orthogonal,
already-flagged caveat from report 31, not something this pass re-checked)
-- but the design choice to use live position rather than a home-cell
model is not a hypothetical to weigh; it is the actual, current,
committed implementation.
Recommendation¶
Building or authoring a real "NPC home/workplace location" data model for Chronicle is NOT WORTH PURSUING right now. Reasoning:
- The data exists and is real (F1-F3), but coverage is uneven by
construction, not by this pass's limitation. Some named-cast NPCs
(Amren, Braith) have a clean, one-hop static answer. Others (Ysolda --
and this pass only checked 3 of 19; there is no reason to expect the
other 16 split any better) require a second-order reverse-index lookup
that is real per CommonLibSSE-NG's headers (F4) but meaningfully harder
to implement, and this pass's own attempt to reproduce it against the
actual pinned Mutagen version came back empty. A home-location model
built today would need a per-NPC fallback path anyway (some NPCs
resolve, some don't) -- which is exactly the shape
EvidencePoller.cppalready has today, for free, using live position as the universal case rather than the exceptional one. - No roadmap item currently needs "the scene of a specific incident"
as distinct from "near this NPC." Report 31 already established that
chronicle/claims.py'sEvidencedataclass has no location concept at all (belief-scoped, not event-scoped) -- adding a home-location table would need a second, independent design decision (what "the scene of an incident" even means, tracked separately from any NPC's current or home position) before it would pay for anything. That decision was correctly out of scope for report 31 and remains out of scope here; nothing in this pass surfaced a concrete feature that needs it. - The already-shipped alternative is uniformly cheaper and already
working.
EvidencePoller.cpp's live-position spawn needs no new Mutagen authoring, no version-sensitive reverse-index code, no new identity table, and treats all 19 named-cast NPCs identically. The marginal value of "spawn it at their house instead of wherever they are standing" is a flavor improvement (arguably a worse one for evidence discoverability -- the player is more likely to encounter the NPC in person than to go find their house), not a capability unlock. - If a real future need appears -- e.g., a genuine "incident scene" concept independent of any single NPC's position, or a design that specifically wants "spawn evidence at the believer's home even when they're not there" -- revisit F3's clean case first (it is real, cheap, and already proven against real data for at least 2 NPCs) and budget real engineering time for F4's reverse-index path before assuming full 19-NPC coverage is achievable the same way.
Classification: real static home-location data is [BUILD-ON]-tier
where a plain home package exists (F3, same tier as report 31's plain-
field mechanisms) — but pursuing it as a Chronicle feature right now is a
non-problem, not a gap: EvidencePoller.cpp's existing live-position
proxy is sufficient for the foreseeable roadmap and should stay the
answer until a concrete design need for scene-independent location data
is identified.
Caveats¶
- Only 3 of 19 named-cast NPCs were sampled (as the task requested). F3's clean 2/3 hit rate is a real, direct-data result, not an estimate, but it should not be read as "most named-cast NPCs have a home package" -- it's one data point at n=3. A future pass wanting a real coverage number would need to run this pass's same dump against all 19.
- F4's reverse-index dead end is inconclusive, not a refutation. This pass confirmed the mechanism is real (header-documented on both the CommonLibSSE-NG and Mutagen sides) but did not successfully resolve Ysolda's actual home location through it -- possibly a remaining schema- navigation mistake in this pass's own scratch code (the 23.4.0 property names are unusually generic and easy to misuse), possibly a genuine limit of what's recoverable outside a live game session. Either way, treat "no home found for an NPC without an explicit HomePackage" as the honest current state, not as proof no such data exists.
- The local
mutagen-srcgit checkout used for schema reading in this report and report 31 is confirmed to be a different (newer) version than the23.4.0package this project's owntools/chronicle-patcher/actually builds against. This did not affect report 31's findings (those schema reads matched 23.4.0's real behavior, spot-checked in this pass), but F4 shows it can matter when a schema's shape changed between versions (UniqueActorReferencesStaticvs.LocationCellUniques) -- future passes readingmutagen-srcfor schema ground-truth should cross-check against the installed package's actual members (e.g., viaSystem.Reflection, as done here) before assuming the git checkout's field names are what code will actually compile against. - No live-game verification of any kind in this pass, consistent with every prior source-reading-tier report in this series.
- This pass did not touch
chronicle/,adapters/skyrim/ChronicleBridge/, ortools/chronicle-patcher/, and did not create a design-prep doc, per this task's own scope (research-and-file only). The standalone .NET dump program used to produce F3/F4's real output lives only in this session's scratchpad, not in the repo.