Skip to content

Full-codebase audit: 74 verified bug, performance, and memory fixes (9.1.0)#116

Open
logicallysynced wants to merge 6 commits into
mainfrom
sharlayan-9-rebuild
Open

Full-codebase audit: 74 verified bug, performance, and memory fixes (9.1.0)#116
logicallysynced wants to merge 6 commits into
mainfrom
sharlayan-9-rebuild

Conversation

@logicallysynced

@logicallysynced logicallysynced commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

This is the output of a full audit of the library: every reader, resolver, mapper, the process-memory core, the harness, and the tests. The FFXIVClientStructs submodule is external and was left alone. The sweep produced 114 raw findings; each one was re-verified against the actual code before any fix went in, which eliminated 16 false positives and merged the duplicates, leaving 74 confirmed issues. This PR fixes all 74.

Build is clean with zero warnings and all 190 tests pass, including a new integrity test that pins the inventory container stride to the FCS struct size. DEPENDENCY.md and the FCS integrity tests are synced with the new mapper fields. Version goes to 9.1.1: five fixes change public API surface, so this is a minor bump, not a patch, plus a patch tick for the submodule bump below.

This branch also merges current main (the earlier FCS bump and harness enmity-table work landed there as #115) and bumps the FFXIVClientStructs submodule to 15ae1806b - 38 upstream commits absorbed with no Sharlayan-side changes, which is the zero-touch offset-derivation doing its job.

Fixes that change observable output

Four corrections return real data where the old code returned wrong or empty values. Everything else in this PR preserves output exactly.

Where What was wrong What changed
Reader.Actor.cs:159 GetEventObjectType called with IntPtr.Zero instead of the actor's address Replaced targetAddress (always IntPtr.Zero) with characterAddress in the GetEventObjectType call so EventObject actors get their real type.
Reader.Inventory.cs:19 Wrong InventoryContainer struct stride (_inventoryByteCount = 24, actual = 32) Added SourceSize property to Sharlayan.Models.Structures.InventoryContainer, populated it via Marshal.SizeOf<NativeInventoryContainer>() in InventoryContainerMapper.Build(), and replaced the hard-coded _inventoryByteCount = 24 constant in Reader.Inventory.cs with this._memoryHandler.Structures.InventoryContainer.SourceSize (correct stride is 32, matching the FCS [StructLayout(Size=0x20)] declaration).
Reader.Inventory.cs:82 MateriaType reads one byte from a ushort field - truncates materia IDs > 255 Read each materia slot as ushort (stride was already 2 bytes) and widened the Inventory.MateriaType enum from byte to ushort backing, so Dawntrail grade VIII/IX/X materia ids above 255 are preserved.
Resources/Mappers/PlayerInfoMapper.cs:140 RPR and SGE missing from PlayerInfo model and mapper - Reaper/Sage levels and EXP always read as 0 Added RPR/SGE/RPR_CurrentEXP/SGE_CurrentEXP properties and wired them end-to-end: structure offsets from ExpIdx 28/29 in PlayerInfoMapper, public properties on Core/PlayerInfo and IPlayerInfo, assignments in PlayerInfoResolver. Same pattern as VPR and PCT.

Public API changes (the 9.1 part)

Five fixes touch types that consumers compile against. Chromatics needs a rebuild and two one-line tweaks (DrawnCards.Count becomes DrawnCards.Length in JobGaugeB and JobGaugeC); everything else it uses is shape-compatible.

Where What was wrong What changed
Models/ReadResults/ActionResult.cs:17 ConcurrentBag allocated fresh on every GetActions() call Changed ActionResult.ActionContainers from ConcurrentBag<ActionContainer> to List<ActionContainer>. The reader populates it sequentially; the bag's thread-local machinery was overhead with no concurrent producer.
Models/ReadResults/InventoryResult.cs:17 ConcurrentBag allocated fresh on every GetInventory() call Changed InventoryResult.InventoryContainers from ConcurrentBag<InventoryContainer> to List<InventoryContainer>, same reasoning.
Reader.Inventory.cs:81 new Inventory.MateriaType[5] and new byte[5] arrays allocated for every non-empty inventory item Items with no melds (the common case) now share static zero-filled length-5 MateriaTypes/MateriaRanks arrays; per-item arrays are allocated only when materia is present. Property types are unchanged; treat the arrays as read-only.
Utilities/ActorItemResolver.cs:246 String interpolation for fallback actor name allocates on every nameless actor Fallback names for nameless actors (TypeID: n / EventObjectTypeID: n) are cached per type id, so the interpolated string is built once per distinct id instead of once per nameless actor per poll.
Utilities/JobResourceResolver.cs:34 Per-poll heap allocations: new List in Astrologian/Dancer resolvers and new array in Monk resolver AstrologianResources.DrawnCards is now AstrologianCard[] (was List<AstrologianCard>), DancerResources.Steps is now DanceStep[] built directly without a temp array (CurrentStep adapted, defaults to empty), and MonkResources.BeastChakra is computed from BeastChakra1/2/3 on access instead of being allocated every poll.

Bug fixes (output-preserving)

Where What was wrong What changed
Core/ActorItem.cs:156 ActorItem.Clone() silently drops StatusNameEnglish from every cloned StatusItem Added StatusNameEnglish = statusItem.StatusNameEnglish, to the StatusItem object initializer inside the foreach (StatusItem statusItem in this.StatusItems) loop in ActorItem.Clone(), matching the existing field-copy pattern for all other StatusItem properties.
Core/PartyMember.cs:33 PartyMember.Clone() silently drops StatusNameEnglish from every cloned StatusItem Added StatusNameEnglish = item.StatusNameEnglish, to the StatusItem object initializer inside the foreach (StatusItem item in this.StatusItems) loop in PartyMember.Clone(), matching the existing field-copy pattern.
Utilities/ActionLookup.cs:42 NullReferenceException on first call to DamageOverTimeActions and HealingOverTimeActions Removed the if (_damageOverTimeActions.Any()) and if (_healingOverTimeActions.Any()) guards that dereferenced null on the first call to DamageOverTimeActions() and HealingOverTimeActions().
Utilities/ChatEntry.cs:54 IndexOf returns -1 when chat line has no colon separator, causing Substring(-1) exception and silent null PlayerName Added int sepIdx = chatLogEntry.Line.IndexOf(INDEX_CHECK, ...) and guarded the Substring and Message.Replace calls inside if (sepIdx >= 0), so a colon-free public chat line flows through without throwing ArgumentOutOfRangeException.
Utilities/PartyMemberResolver.cs:81 Coordinate constructed with Z passed twice instead of Y and Z Changed the third argument of new Coordinate(entry.X, entry.Z, entry.Z) to entry.Y at PartyMemberResolver.cs:81. The typo caused the Y component to equal Z for every party member resolved via the raw-bytes path.
LuminaLookup.cs:100 Process-wide lookup cache keyed only on typeof(T), ignoring sqpack path - multi-instance data contamination Changed LuminaLookup._cache key from Type to (Type, string) tuple where the string is the lower-invariant sqpack directory path resolved from SharlayanConfiguration via a new ResolveSqpackKey helper (same logic as LuminaGameDataCache.TryResolveSqpackPath).
MemoryHandler.cs:244 GetUInt16 reads 4 bytes instead of 2 - over-reads process memory Changed new byte[4] to new byte[2] in GetUInt16 so the method reads exactly the 2 bytes it needs instead of over-reading across a potential page boundary.
MemoryHandler.cs:259 GetUInt64 calls TryToUInt32 - silently truncates upper 32 bits Changed SharlayanBitConverter.TryToUInt32(value, 0) to SharlayanBitConverter.TryToUInt64(value, 0) in GetUInt64; the method was reading 8 bytes correctly but discarding the upper 32 bits by calling the wrong converter.
Reader.Actions.cs:136 HotbarSlot.CommandId read with TryToInt16 - truncates action IDs > 32767 Changed the ActionItem.ID assignment in Reader.Actions.cs from SharlayanBitConverter.TryToInt16(hotbarMap, ...) to (int)SharlayanBitConverter.TryToUInt32(hotbarMap, ...), matching the FCS HotbarSlot.CommandId field width (uint at [FieldOffset(0xB8)]) so action IDs above 32767 are no longer truncated.
Reader.CurrentPlayer.cs:128 GetInt16 reads only 2 bytes from a 4-byte int field (Hater.HaterCount) Changed this._memoryHandler.GetInt16(...) to this._memoryHandler.GetInt32(...) and the local variable type from short to int for agroCount, matching the 4-byte int HaterCount field declared in FFXIVClientStructs.
Reader.CurrentPlayer.cs:84 targetAddress is always IntPtr.Zero in GetCurrentPlayerEntity - TargetID read never executes Removed the dead IntPtr targetAddress = IntPtr.Zero declaration, the if (targetAddress.ToInt64() > 0) conditional block (always false, so TargetID was never read from that path), and the targetInfoMap buffer allocation and pool return.
Reader.Inventory.cs:68 uint itemId <= 0 comparison is always-false dead branch Changed the always-misleading if (itemId <= 0) comparison (where itemId is uint) to if (itemId == 0) to express the empty-slot intent precisely and eliminate the dead < 0 branch.
Reader.Target.cs:118 GetInt16 reads only 2 bytes from a 4-byte int field (Hate.HateArrayLength) Changed short enmityCount = GetInt16(counter) to int enmityCount = GetInt32(counter) in Reader.Target.cs, matching the 4-byte int HateArrayLength field declared in FCS.
Resources/Mappers/PlayerInfoMapper.cs:72 Marshal.OffsetOf used on internal PlayerState fields - inconsistent with project pattern, fragile if PlayerState gains non-marshalable member Replaced the three Marshal.OffsetOf<PlayerState>("_classJobLevels"), Marshal.OffsetOf<PlayerState>("_classJobExperience"), Marshal.OffsetOf<PlayerState>("_attributes") calls and the Marshal.SizeOf<PlayerState>() call in PlayerInfoMapper.Build() with the project-standard FieldOffsetReader.OffsetOf<PlayerState>(...) and FieldOffsetReader.SizeOf<PlayerState>() equivalents, matching every other mapper in the codebase.
Resources/Providers/FFXIVClientStructsSignatureExtractor.cs:143 Short-name cache key collision silently overwrites earlier entries - wrong signature used for any two FCS types sharing an unqualified name In FFXIVClientStructsSignatureExtractor.BuildCache, changed cache[t.Name] = info to cache.TryAdd(t.Name, info) so the first [StaticAddress] method wins on short-name collision, and added a break after registering the first [StaticAddress]-decorated method per type so that a type with multiple such methods does not silently overwrite the short-name entry on each iteration.
Scanner.cs:188 LoadRegions fires RaiseException for every non-writable memory region - floods exception handler Removed the else branch that raised an exception event for every region not added to the scan list. Skipping read-only, system-module, and guard pages is expected behavior, not an error, and a 64-bit FFXIV process has thousands of them per scan.
Sharlayan.Harness/Program.cs:771 LiveRenderer._previousLineCount never decreases - redundant blank-line writes on every tick after display shrinks In LiveRenderer.End(), saved _thisFrameLineCount into contentLines before the ghost-clearing loop, then assigned _previousLineCount = contentLines (instead of Math.Max) so the high-water mark shrinks when the display gets shorter.
Sharlayan.Tests/Resources/Mappers/RecastItemMapperTests.cs:97 ContainerSize test uses the same literal as the mapper - can never detect FCS layout drift Added using FFXIVClientStructs.FFXIV.Client.UI.Arrays; to the test file and replaced 272 * sizeof(int) in the Build_ContainerSize_MatchesActionBarBarNumberArraySize assertion with Marshal.SizeOf<ActionBarNumberArray.ActionBarBarNumberArray>().
Utilities/PartyMemberResolver.cs:116 Status items added to _foundStatuses twice per valid status every poll Removed the unconditional this._foundStatuses.Add(statusEntry) at old line 116 of PartyMemberResolver.cs. Only the single add inside the IsValid() block remains, matching the pattern in ActorItemResolver.

Performance and memory

The library gets polled many times a second, so per-poll allocations and redundant ReadProcessMemory calls add up fast. The headline change: GetActors used to deep-clone every tracked actor on every poll to build its removed-actor sets, then throw nearly all of those clones away. It now tracks present IDs in a HashSet and clones only the actors that are genuinely gone. GetGameState had a similar problem, running the full player-resolution pipeline every frame to answer a yes/no logged-in question that a single pointer read answers.

Where What was wrong What changed
Reader.Actor.cs:77 Per-poll Clone() of every tracked actor to build removal candidates Replaced the pre-scan clone-everything approach with three local HashSet<uint> of present IDs populated during the address scan; clones are produced only for entries absent from those sets afterwards, so a steady-state poll allocates nothing for removal tracking.
Reader.ChatLog.cs:53 new ChatLogPointers struct assigned on every poll call Resolved by the MemoryHandler change below: the primitive getters behind the ChatLogPointers refresh now read into thread-static buffers instead of allocating.
Reader.GameState.cs:104 GetGameState calls GetCurrentPlayer() on every poll, duplicating all CurrentPlayer ReadProcessMemory work Replaced the full GetCurrentPlayer() pipeline in GetGameState() with an 8-byte CHARMAP pointer read. A non-zero pointer means logged in; a zero pointer falls back to the LoggedInStateLatch cache, preserving the existing zone-transition behavior without re-running actor resolution every frame.
Core/ActorItemBase.cs:92 Math.Pow(x, 2) used for squaring in hot-path distance methods - should be x*x Replaced Math.Pow(distanceX, 2) with distanceX * distanceX (and likewise for Y and Z) in both GetDistanceTo and GetHorizontalDistanceTo in ActorItemBase.cs, eliminating the transcendental-function overhead on the hot polling path.
Core/Coordinate.cs:51 Math.Pow(x, 2) used for squaring in Coordinate distance and normalize methods Replaced all Math.Pow(expr, 2) calls with expr * expr in Coordinate.Distance2D, Coordinate.DistanceTo, and Coordinate.Normalize, using local double dx/dy/dz variables to avoid re-evaluating the subtraction expressions.
MemoryHandler.cs:147 GetByte allocates a new byte[1] on every call Added a [ThreadStatic] private static byte[] _singleByteBuffer field; GetByte lazily initializes it once per thread and reuses it, eliminating the per-call new byte[1] allocation.
MemoryHandler.cs:193 GetString allocates byte[256] + Array.Resize on every call Rewrote GetString to rent a buffer from BufferPool, find the null terminator in-place, call Encoding.UTF8.GetString(bytes, 0, realSize) directly, and return the buffer in a finally block - eliminating both the new byte[size] and Array.Resize allocations.
MemoryHandler.cs:210 GetStringFromBytes allocates a new byte[] copy for every string decode Rewrote GetStringFromBytes to scan for the null terminator directly in the source array at source[offset + i], then call Encoding.UTF8.GetString(source, offset, realSize) - eliminating the intermediate new byte[size] copy and the Array.Resize allocation.
MemoryHandler.cs:211 GetStringFromBytes uses LINQ Any() on a byte array - unnecessary allocation on hot path Replaced if (!source.Any()) with if (source.Length == 0) in GetStringFromBytes, eliminating the LINQ enumerator allocation on every call.
MemoryHandler.cs:237 GetStructure leaks unmanaged CoTaskMem if PtrToStructure throws Wrapped the GetStructure<T> body in a try/finally so Marshal.FreeCoTaskMem is called unconditionally even if PtrToStructure throws, eliminating the native memory leak path.
Reader.ChatLog.cs:50 List<byte[]> allocated every GetChatLog call even when no new messages exist Replaced the per-call List<byte[]> local with an instance field cleared at the start of each call, safe under the new GetChatLog lock, so steady-state polling reuses one list.
Reader.ChatLog.cs:95 bytes.Any() LINQ call on every chat buffer entry Replaced !bytes.Any() with bytes.Length == 0 in the foreach loop over bufferList, eliminating the LINQ enumerator allocation per chat buffer entry.
Reader.GameState.cs:165 new BgmSceneInfo[sceneCount] and up to 12 new BgmSceneInfo objects allocated every poll frame Added private readonly BgmSceneInfo[] _bgmSceneCache = new BgmSceneInfo[MaxBgmScenes] field; the scene loop now lazily creates BgmSceneInfo objects on first use and overwrites their fields in place each frame.
Reader.GameState.cs:227 FFT-read GetByteArray allocates a fresh 32-byte byte[] on every GetGameState poll frame Added private readonly byte[] _fftScratch = new byte[BgmAudibleBinCount * sizeof(float)] field on the Reader partial class in Reader.GameState.cs and switched the FFT read to use the pre-allocated scratch buffer via the void GetByteArray overload, eliminating the new byte[32] allocation on every GetGameState poll.
Reader.GameState.cs:241 Lumina .ExtractText() called on every GetGameState poll for weather and BGM name Added cached id-to-name fields for weather, BGM, and scene BGM files, so Lumina .ExtractText() runs only when an id changes rather than on every poll.
Reader.Inventory.cs:62 slotBytes allocated fresh on every container iteration - not returned to pool Added a count-bounded Peek/GetByteArray overload to MemoryHandler and switched GetInventory to one pooled slot buffer reused across containers, cleared on a failed read so output matches the old fresh-array behavior.
Reader.Lumina.cs:34 GetZoneName performs two uncached GetSheet<T> dictionary lookups on every call Added per-language cached sheet dictionaries (_territorySheets, _placeNameSheets, _weatherSheets) so GetSheet<T> resolves once per language instead of on every call.
Reader.PartyMembers.cs:39 Per-poll Clone() of every tracked PartyMember to build removal candidates Dropped the Clone() when building removal candidates. Removed members are pruned from tracking before the result returns and the library never touches them again, and the main PartyMembers collection already hands callers live references, so the data callers see is unchanged.
Reader.PartyMembers.cs:72 ResolvePartyMemberFromBytes called and its result discarded when existing member is already tracked Moved if (existing != null) { continue; } to immediately after existing is resolved - before the ResolvePartyMemberFromBytes call.
Reader.Target.cs:190 GetMapInfo() called once per target actor (up to 4 redundant ReadProcessMemory round-trips per poll) Hoisted (uint mapID, uint mapIndex, uint mapTerritory) = this.GetMapInfo() into GetTargetInfo() (called once per poll, before the four per-target calls) and passed the tuple as parameters to GetTargetActorItemFromSource(long, uint, uint, uint).
Reader.cs:20 Field initializers allocate 5 delegate objects that the constructor immediately discards Removed the = new XxxWorkerDelegate() field initializers from all five delegate fields (_chatLogWorkerDelegate, _monsterWorkerDelegate, _npcWorkerDelegate, _partyWorkerDelegate, _pcWorkerDelegate), keeping only the constructor assignments that were already there and that supply the instances actually used by the resolvers.
Scanner.cs:223 SignatureToByte allocates a new byte array inside the inner scan loop for every signature × every buffer window Added a pre-conversion step in FindExtendedSignatures that calls SignatureToByte once per signature before entering any scan loop, storing results in a parallel array the scan loops index into, cutting conversions from signatures-times-windows to once per signature.
SharlayanMemoryManager.cs:24 AddHandler leaks the previous MemoryHandler when replacing an existing process ID Changed the AddOrUpdate update factory in AddHandler to call oldHandler.Dispose() before returning the new handler, closing the leaked ProcessHandle and stopping the orphaned background scan task when the same process ID is re-registered.
Utilities/ActorItemResolver.cs:181 FirstOrDefault LINQ on StatusItems list for every status slot per actor per poll Replaced entry.StatusItems.FirstOrDefault(x => x.CasterID == casterID && x.StatusID == statusID) with an index-based for loop over entry.StatusItems in ActorItemResolver.cs.
Utilities/ChatCleaner.cs:140 ProcessName uses uncompiled Regex.Replace calls for patterns already covered by compiled static fields Replaced the two Regex.Replace(cleaned, @"[\r\n]+", ...) and Regex.Replace(cleaned, @"[\x00-\x1F]+", ...) calls in ProcessName with NewLineRegex.Replace(...) and NoPrintingCharactersRegex.Replace(...), using the pre-compiled static fields already declared at the top of the class.
Utilities/ChatCleaner.cs:44 ProcessFullLine allocates multiple intermediate strings, a List, and calls bytes.ToArray() / bytes.Count() (LINQ) per chat message In ProcessFullLine: removed bytes.ToArray() (bytes is already byte[]) so Encoding.UTF8.GetString receives the array directly; replaced bytes.Count() with bytes.Length, eliminating the LINQ enumerator allocation per message.
Utilities/ChatEntry.cs:30 Per-message LINQ chains and redundant array copies allocate multiple temporary arrays Replaced raw.Take(4).Reverse().ToArray() and raw.Skip(4).Take(2).Reverse().ToArray() with manually indexed byte arrays, replaced Encoding.UTF8.GetString(raw.ToArray()) with Encoding.UTF8.GetString(raw), and replaced raw.Skip(8).ToArray() with Buffer.BlockCopy(raw, 8, cleanable, 0, cleanableLength), eliminating all four LINQ-chain and redundant-copy allocations per message.
Utilities/ChatEntry.cs:68 ByteArrayToString uses an interpolated string inside AppendFormat, allocating a string per byte Changed hex.AppendFormat($"{b:X2}") to hex.AppendFormat("{0:X2}", b), eliminating the per-byte interpolated string allocation inside ByteArrayToString.
Utilities/ChatLogReader.cs:60 ResolveEntries allocates a new List<byte[]> and calls EnsureArrayIndexes (buffer rent+RPM+BoxedInt list population) on every call Removed the EnsureArrayIndexes() call from ResolveEntries and instead called it once in GetChatLog's else branch before any ResolveEntries invocations, so a ring-wrap (two consecutive ResolveEntries calls) no longer performs two redundant RPM round-trips of the 4000-byte index array.
Utilities/ChatLogReader.cs:68 entry.Any() LINQ call per resolved entry Replaced entry.Any() with entry.Length > 0 in ResolveEntries, removing the LINQ enumerator allocation per resolved chat entry.
Utilities/ChatLogReader.cs:79 ResolveEntry allocates a new byte[] result that is never pooled ResolveEntry now calls this._memoryHandler.GetByteArray(address, result) (the void overload) directly into the heap result array, eliminating the rented intermediate buffer and the Buffer.BlockCopy.
Utilities/PartyMemberResolver.cs:102 FirstOrDefault LINQ on StatusItems list per status slot in party member resolver Replaced entry.StatusItems.FirstOrDefault(x => x.CasterID == casterID && x.StatusID == statusID) with an index-based for loop over entry.StatusItems in PartyMemberResolver.cs.

Concurrency and interop

Where What was wrong What changed
MemoryHandler.cs:107 IsAttached written from the game-exit event thread without a memory barrier - polling thread may see stale value Replaced the IsAttached auto-property with a volatile backing field so writes from the process-exit event thread are immediately visible to the polling thread.
MemoryHandler.cs:238 GetStructure uses Process.Handle instead of the explicitly opened ProcessHandle Replaced this.Configuration.ProcessModel.Process.Handle with this.ProcessHandle in the ReadProcessMemory call inside GetStructure<T>, matching every other call site in the class.
Reader.ChatLog.cs:41 _chatLogReader.PreviousArrayIndex and .PreviousOffset mutated directly from GetChatLog - two simultaneous calls produce interleaved cursor state Added private readonly object _chatLogLock = new object() field and wrapped the entire post-guard body of GetChatLog in lock (this._chatLogLock), preventing interleaved cursor state if two threads call simultaneously.
UnsafeNativeMethods.cs:37 CloseHandle P/Invoke return type is int instead of bool Added [return: MarshalAs(UnmanagedType.Bool)] and changed the return type of the CloseHandle P/Invoke declaration from int to bool, matching the Win32 BOOL typedef and the established pattern used by ReadProcessMemory in the same file.
Utilities/ActionLookup.cs:24 _loading flag in ActionLookup/StatusEffectLookup/ZoneLookup has no volatile semantics - concurrent callers can double-load Declared _loading as private static volatile bool _loading in all three lookup singletons (ActionLookup, StatusEffectLookup, ZoneLookup), ensuring the check-then-set sequence sees a current value across threads without the risk of a stale cached read on the checking thread.
Utilities/ActorItemResolver.cs:26 _foundStatuses is a shared instance-level List<StatusItem> cleared and mutated on every resolve call - unsafe if ResolveActorFromBytes is ever called concurrently Removed the shared private readonly List<StatusItem> _foundStatuses instance field from ActorItemResolver and replaced it with List<StatusItem> foundStatuses = new List<StatusItem>() declared as a local variable inside ResolveActorFromBytes.
Utilities/LuminaGameDataCache.cs:43 _cache.GetOrAdd(sqpack, CreateGameData) may invoke CreateGameData on multiple threads for the same key Changed _cache from ConcurrentDictionary<string, GameData> to ConcurrentDictionary<string, Lazy<GameData>> and updated both GetOrNull and Get to use GetOrAdd(key, k => new Lazy<GameData>(() => CreateGameData(k))).Value, ensuring the CreateGameData factory is called at most once per sqpack path even under concurrent racing callers.

Robustness

Where What was wrong What changed
Reader.Target.cs:141 NullReferenceException when enmity entry caster is unknown (pc and npc both null) Replaced (pc ?? npc).Name with the null-safe chain pc?.Name ?? npc?.Name ?? monster?.Name ?? string.Empty, producing the same empty-name result for unknown casters without throwing and catching an exception per entry.
Reader.Inventory.cs:62 Unbounded allocation from untrusted container.Amount read from foreign process Added private const int _inventoryMaxSlots = 600 and a guard if (container.Amount > _inventoryMaxSlots) continue; in Reader.Inventory.cs before the slot-array allocation, preventing any oversized RPM or OOM from a torn read of container.Amount.
Resources/Providers/LuminaGameDataCache.cs:77 Process.MainModule.FileName accessed outside try/catch in GetOrNull - can throw Win32Exception and propagate from an API that is advertised as never throwing Wrapped the Process.MainModule.FileName access in TryResolveSqpackPath in a try/catch that returns null, so GetOrNull keeps its never-throws contract when module info is unavailable.
Scanner.cs:68 LoadOffsets Task.Run has no fault continuation - scan exceptions are silently swallowed Wrapped the Task.Run body in try/finally so IsScanning = false is always reached even when an exception escapes, and added a .ContinueWith(..., OnlyOnFaulted) continuation that logs the fault and routes it to RaiseException, consistent with the two analogous Task.Run calls in the MemoryHandler constructor.
Sharlayan.Harness/Program.cs:121 MemoryHandler (IDisposable) never disposed - OS handle leaks on harness exit Wrapped the harness body in try/finally so the MemoryHandler is disposed on both exit paths.
Sharlayan.Harness/Program.cs:73 Extra Process objects from GetProcessesByName not disposed - OS handles leaked After Process game = candidates[0];, added a for loop that calls candidates[i].Dispose() for every index >= 1, releasing the OS process handles for any extra FFXIV instances that were returned by GetProcessesByName but not used.
Utilities/ChatCleaner.cs:55 No bounds check before bytes[x+2] and subsequent index advances in chat byte parser Added if (x + 2 >= bytes.Length) break; before the bytes[x + 2] read in case 2, and if (x + 4 >= bytes.Length) break; before the bytes[x + 4] read in the length==1 sub-path, so a truncated payload byte sequence breaks out of the switch cleanly instead of throwing.

How this was audited

Eleven parallel reviewers swept disjoint parts of the codebase: eight by subsystem, three cross-cutting lenses for allocations, thread safety, and P/Invoke correctness. Every finding then went to an adversarial verifier that tried to refute it against the code; high-severity findings needed two independent confirmations to survive. A coverage pass at the end hunted for files and issue classes the sweep missed and contributed six of the confirmed findings. The full report with evidence and verifier notes for all 74 findings is in reports/audit-20260611.md, kept local since reports/ is gitignored.

🤖 Generated with Claude Code

logicallysynced and others added 4 commits June 5, 2026 11:13
The harness previously only surfaced enmity via TargetInfo.EnmityItems,
which Reader.Target.cs gates behind CurrentTargetID > 0 — so the table
was invisible when the player had no target. Two distinct enmity-related
structures exist on UIState and both deserve visibility for testing:

  AGRO  list (UIState.Hater._haters, HaterCount)
    AGROMAP_KEY / AGRO_COUNT_KEY — "who is aggro'd on the player"
    Already surfaced on PlayerInfo.EnmityItems; reprinted with the raw
    counter alongside for verification.

  HATE  list (UIState.Hate._hateInfo, HateArrayLength)
    ENMITYMAP_KEY / ENMITY_COUNT_KEY — "the player's own hate table"
    Read directly from memory using HateItem offsets so the data is
    visible regardless of whether GetTargetInfo would populate it.
    Names looked up against GetActors() since hate entries store only
    ID + Enmity.

Also logs CanGetAgroEntities / CanGetEnmityEntities capability flags so
a stripped scan (missing signature) is obvious at a glance, and lists
up to 10 actors with IsAgroed || InCombat set — independent of either
table — as an eye-check that ActorItem.IsAgroed lights up correctly.

The note above the TargetInfo.EnmityItems print is updated to point at
[3c.3] so it's clear why the same data shows up twice in different forms.

Harness-only — no Sharlayan source changes, no version bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Upstream rolled two commits since the previous pin:

  b21433ecc Add names from EventSystemDefine as comments
  e04ec6b37 Update structs

Routine struct refresh + a comments-only addition. No public-API,
field-rename, or layout-shape changes that Sharlayan would notice.
Lumina 7.5.0, Lumina.Excel 7.4.3, NLog 6.1.3, Newtonsoft.Json 13.0.4,
and ILRepack.Lib.MSBuild.Task 2.0.45 are already at latest stable
(verified via `dotnet list package --outdated` — no available updates).

Build clean, 189/189 tests pass, merged Sharlayan.dll byte size
unchanged at 10980 KB, debug DLLs deployed to Chromatics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s, resolvers, and memory core

Multi-agent audit of the full Sharlayan codebase (FCS submodule excluded),
every finding adversarially verified before fixing. Highlights:

- ActorItem/PartyMember.Clone() now copy StatusNameEnglish (was silently null)
- GetEventObjectType receives the real actor address (was always IntPtr.Zero)
- Inventory container stride derived from FCS Size (was hardcoded 24 vs actual 32)
- Materia IDs read as ushort (Dawntrail grades > 255 no longer truncated)
- RPR/SGE levels + EXP wired end-to-end in PlayerInfo (were always 0)
- Party member Coordinate Y/Z typo; duplicate status adds; enmity NRE chain
- GetActors clones only genuinely-removed actors (was deep-cloning everything per poll)
- GetGameState derives IsLoggedIn from a pointer read (was running full player resolve)
- MemoryHandler primitive getters use thread-static buffers (zero alloc per read)
- Chat pipeline: bounds guards, compiled regexes, LINQ/copy churn removed
- Scanner: per-region exception flood removed, signature bytes hoisted out of scan loop
- P/Invoke hygiene: CloseHandle BOOL, GetStructure try/finally + correct handle
- DEPENDENCY.md + FCS integrity tests synced (InventoryContainer.SourceSize, RPR/SGE)

Build clean, 190/190 tests pass. Version 9.0.40.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ActionResult.ActionContainers / InventoryResult.InventoryContainers:
  ConcurrentBag<T> -> List<T> (populated sequentially; the bag's
  thread-local machinery was pure overhead per poll)
- AstrologianResources.DrawnCards: List<AstrologianCard> -> AstrologianCard[]
- DancerResources.Steps: List<DanceStep> -> DanceStep[] (built directly,
  no temp array); CurrentStep adapted, Steps defaults to empty
- MonkResources.BeastChakra: stored array -> computed from BeastChakra1-3
  (zero per-poll allocation; values identical on access)
- InventoryItem materia arrays: shared zero-filled length-5 arrays for
  items with no melds; per-item arrays only when materia present
- ActorItemResolver: fallback names for nameless actors cached per type id

Minor version to 9.1 for the public API surface changes (authorized).
Build clean, 190/190 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@logicallysynced logicallysynced changed the title Full-codebase audit: 69 verified bug, performance, and memory fixes Full-codebase audit: 74 verified bug, performance, and memory fixes (9.1.0) Jun 11, 2026
@logicallysynced logicallysynced self-assigned this Jun 11, 2026
logicallysynced and others added 2 commits June 11, 2026 16:31
38 upstream commits (struct updates, WeatherInterface, TerritoryInfo/
MapRangeFlags updates, EventFramework.SetDirectorData). Build clean,
190/190 tests pass including the FCS dependency integrity suite -
no Sharlayan-side changes needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant