diff --git a/.gitignore b/.gitignore
index 4ce6fdd..7295810 100644
--- a/.gitignore
+++ b/.gitignore
@@ -167,6 +167,9 @@ DocProject/Help/html
# Click-Once directory
publish/
+# CP2077SaveExporter: self-contained publish output (see CP2077SaveExporter/publish.sh, publish.ps1)
+CP2077SaveExporter/publish/
+
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
diff --git a/CP2077SaveExporter/CP2077SaveExporter.csproj b/CP2077SaveExporter/CP2077SaveExporter.csproj
new file mode 100644
index 0000000..30a881a
--- /dev/null
+++ b/CP2077SaveExporter/CP2077SaveExporter.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ CP2077SaveExporter
+ CP2077SaveExporter
+ x64
+ x64
+ latest
+ false
+ $(NoWarn);CS1591
+
+
+
+
+
+
+
+
+
+
diff --git a/CP2077SaveExporter/ExportTransforms.cs b/CP2077SaveExporter/ExportTransforms.cs
new file mode 100644
index 0000000..d6b8723
--- /dev/null
+++ b/CP2077SaveExporter/ExportTransforms.cs
@@ -0,0 +1,1283 @@
+namespace CP2077SaveExporter;
+
+/// Explicit read-only transforms from raw extraction to normalized/derived DTOs.
+internal static class ExportTransforms
+{
+ /// Count of body cyberware equipment areas used by the exporter (matches ProgressExtractor cyberware filter).
+ public const int CyberwareSlotsPossible = 19;
+
+ private static readonly HashSet CoreStatNames = new(StringComparer.Ordinal)
+ {
+ "Strength",
+ "Reflexes",
+ "Intelligence",
+ "TechnicalAbility",
+ "Cool",
+ };
+
+ public static CoreAttributesDto SplitCoreAttributes(IReadOnlyList flat)
+ {
+ int? body = null, reflexes = null, intelligence = null, technical = null, cool = null;
+ foreach (var a in flat)
+ {
+ switch (a.StatType)
+ {
+ case "Strength": body = a.Value; break;
+ case "Reflexes": reflexes = a.Value; break;
+ case "Intelligence": intelligence = a.Value; break;
+ case "TechnicalAbility": technical = a.Value; break;
+ case "Cool": cool = a.Value; break;
+ }
+ }
+
+ return new CoreAttributesDto
+ {
+ Body = body,
+ Reflexes = reflexes,
+ Intelligence = intelligence,
+ Technical = technical,
+ Cool = cool,
+ };
+ }
+
+ public static Dictionary SplitOtherStats(IReadOnlyList flat)
+ {
+ var d = new Dictionary(StringComparer.Ordinal);
+ foreach (var a in flat)
+ {
+ if (!CoreStatNames.Contains(a.StatType))
+ {
+ d[a.StatType] = a.Value;
+ }
+ }
+
+ return d;
+ }
+
+ public static HashSet CollectEquippedTweakDbIds(
+ IReadOnlyList equipment,
+ IReadOnlyList cyberware)
+ {
+ var s = new HashSet(StringComparer.Ordinal);
+ foreach (var e in equipment)
+ {
+ s.Add(e.TweakDbIdUlong);
+ }
+
+ foreach (var c in cyberware)
+ {
+ s.Add(c.TweakDbIdUlong);
+ }
+
+ return s;
+ }
+
+ public static HashSet CollectCyberwareTweakDbIds(IReadOnlyList cyberware)
+ {
+ var s = new HashSet(StringComparer.Ordinal);
+ foreach (var c in cyberware)
+ {
+ s.Add(c.TweakDbIdUlong);
+ }
+
+ return s;
+ }
+
+ public static IReadOnlyList NormalizeInventoryRows(
+ IReadOnlyList raw,
+ IReadOnlySet equippedTweakDbIds,
+ IReadOnlySet cyberwareTweakDbIds)
+ {
+ var list = new List(raw.Count);
+ foreach (var row in raw)
+ {
+ var equipped = equippedTweakDbIds.Contains(row.TweakDbIdUlong);
+ var category = InferItemCategory(row.ItemIdResolved, row.ItemIdPresentation, row.Flags);
+ if (cyberwareTweakDbIds.Contains(row.TweakDbIdUlong))
+ {
+ category = "Cyberware";
+ }
+
+ list.Add(new InventoryItemNormalizedDto
+ {
+ InventoryId = row.InventoryId,
+ TweakDbIdUlong = row.TweakDbIdUlong,
+ ItemIdResolved = row.ItemIdResolved,
+ ItemIdPresentation = row.ItemIdPresentation,
+ Quantity = row.Quantity,
+ InferredCategory = category,
+ AppearsEquipped = equipped,
+ AppearsEquippedReason = equipped ? "tweakdbid_match" : "not_matched",
+ });
+ }
+
+ return list;
+ }
+
+ public static IReadOnlyList ToEquippedSlotInstances(IReadOnlyList raw)
+ {
+ return raw.Select(r => new EquippedSlotInstanceDto
+ {
+ AreaTypeLabel = r.AreaTypeLabel,
+ AreaTypeEnum = r.AreaTypeEnum,
+ TweakDbIdUlong = r.TweakDbIdUlong,
+ ItemIdResolved = r.ItemIdResolved,
+ ItemIdPresentation = r.ItemIdPresentation,
+ }).ToList();
+ }
+
+ public static IReadOnlyList ToCyberwareSlotInstances(IReadOnlyList raw)
+ {
+ return raw.Select(r => new CyberwareSlotInstanceDto
+ {
+ AreaTypeEnum = r.AreaTypeEnum,
+ TweakDbIdUlong = r.TweakDbIdUlong,
+ ItemIdResolved = r.ItemIdResolved,
+ ItemIdPresentation = r.ItemIdPresentation,
+ }).ToList();
+ }
+
+ public static DerivedInventorySummaryDto SummarizeInventoryCategories(IReadOnlyList rows)
+ {
+ var w = 0;
+ var c = 0;
+ var cons = 0;
+ var cloth = 0;
+ var qh = 0;
+ var q = 0;
+ var ammo = 0;
+ var distinctTweakIds = new HashSet(StringComparer.Ordinal);
+ foreach (var row in rows)
+ {
+ distinctTweakIds.Add(row.TweakDbIdUlong);
+ switch (row.InferredCategory)
+ {
+ case "Weapon":
+ case "WeaponMod":
+ w++;
+ break;
+ case "Cyberware":
+ c++;
+ break;
+ case "Consumable":
+ cons++;
+ break;
+ case "Clothing":
+ cloth++;
+ break;
+ case "Quickhack":
+ qh++;
+ break;
+ case "Quest":
+ q++;
+ break;
+ case "Ammo":
+ ammo++;
+ break;
+ }
+ }
+
+ return new DerivedInventorySummaryDto
+ {
+ WeaponCount = w,
+ CyberwareCount = c,
+ ConsumableCount = cons,
+ ClothingCount = cloth,
+ QuickhackCount = qh,
+ QuestItemCount = q,
+ AmmoCount = ammo,
+ UniqueItemTypes = distinctTweakIds.Count,
+ };
+ }
+
+ private static readonly HashSet WeaponSlotAreaEnums = new(StringComparer.Ordinal)
+ {
+ "Weapon",
+ "WeaponLeft",
+ "WeaponHeavy",
+ "VDefaultHandgun",
+ "WeaponWheel",
+ };
+
+ public static DerivedEquipmentSummaryDto SummarizeEquipmentLoadout(
+ IReadOnlyList slotsDeduped,
+ IReadOnlyList cyberInstances)
+ {
+ var weapons = 0;
+ foreach (var s in slotsDeduped)
+ {
+ if (WeaponSlotAreaEnums.Contains(s.AreaTypeEnum))
+ {
+ weapons++;
+ }
+ }
+
+ return new DerivedEquipmentSummaryDto
+ {
+ TotalEquippedWeapons = weapons,
+ TotalEquippedCyberware = cyberInstances.Count,
+ };
+ }
+
+ /// Specific categories before broad Weapon match; quest flag from save item flags first.
+ public static string InferItemCategory(string? resolved, string presentation, string flags)
+ {
+ var id = (resolved ?? presentation).ToLowerInvariant();
+ if (id.Length == 0)
+ {
+ return "Unknown";
+ }
+
+ if (flags.Contains("IsQuestItem", StringComparison.Ordinal))
+ {
+ return "Quest";
+ }
+
+ if (id.Contains("items.money", StringComparison.Ordinal)
+ || id.Contains("gen_moneyshard", StringComparison.Ordinal)
+ || id.Contains("money_shard", StringComparison.Ordinal))
+ {
+ return "Currency";
+ }
+
+ // Before broad items.q / "quest" paths so keycards are labeled even under Items.Q*_Keycard.
+ if (id.Contains("keycard", StringComparison.Ordinal) || id.Contains("gen_keycard", StringComparison.Ordinal))
+ {
+ return "Keycard";
+ }
+
+ if (id.Contains("quest", StringComparison.Ordinal) || id.StartsWith("items.q", StringComparison.Ordinal))
+ {
+ return "Quest";
+ }
+
+ if ((id.Contains("mod", StringComparison.Ordinal) && id.Contains("weapon", StringComparison.Ordinal))
+ || id.Contains("w_mod_", StringComparison.Ordinal)
+ || (id.Contains("prt_", StringComparison.Ordinal) && !id.Contains("fabric", StringComparison.Ordinal)))
+ {
+ return "WeaponMod";
+ }
+
+ if (id.Contains("ammo.", StringComparison.Ordinal) || id.Contains("con_ammo", StringComparison.Ordinal))
+ {
+ return "Ammo";
+ }
+
+ if (id.Contains("quickhack", StringComparison.Ordinal) || id.Contains("quick_hack", StringComparison.Ordinal))
+ {
+ return "Quickhack";
+ }
+
+ if (id.Contains("grenade", StringComparison.Ordinal))
+ {
+ return "Grenade";
+ }
+
+ if (id.Contains("cyberwarestatsshard", StringComparison.Ordinal)
+ || id.Contains("cyberwareupgradeshard", StringComparison.Ordinal))
+ {
+ return "Cyberware";
+ }
+
+ if (id.Contains("cyberware", StringComparison.Ordinal) || id.Contains("_cw_", StringComparison.Ordinal))
+ {
+ return "Cyberware";
+ }
+
+ if (id.Contains("gen_craftingmaterial", StringComparison.Ordinal)
+ || id.Contains("craftingmaterial", StringComparison.Ordinal)
+ || id.Contains("items.crafting", StringComparison.Ordinal)
+ || id.Contains("upgrade_component", StringComparison.Ordinal))
+ {
+ return "Crafting";
+ }
+
+ if (id.Contains("gen_readable", StringComparison.Ordinal)
+ || id.Contains("gen_databank", StringComparison.Ordinal)
+ || id.Contains("lore_shard", StringComparison.Ordinal))
+ {
+ return "Readable";
+ }
+
+ if (id.Contains("consumable", StringComparison.Ordinal)
+ || id.Contains("food", StringComparison.Ordinal)
+ || id.Contains("drink", StringComparison.Ordinal)
+ || id.Contains("alcohol", StringComparison.Ordinal)
+ || (id.Contains("items.con_", StringComparison.Ordinal) && !id.Contains("con_ammo", StringComparison.Ordinal)))
+ {
+ return "Consumable";
+ }
+
+ if (id.Contains("shirt") || id.Contains("pants") || id.Contains("outer") || id.Contains("face") || id.Contains("boots") || id.Contains("clothing") || id.Contains("outfit"))
+ {
+ return "Clothing";
+ }
+
+ if (id.Contains("junk", StringComparison.Ordinal))
+ {
+ return "Junk";
+ }
+
+ if (id.Contains("w_melee_", StringComparison.Ordinal))
+ {
+ return "Weapon";
+ }
+
+ if (id.Contains("weapon", StringComparison.Ordinal) || id.Contains("rifle") || id.Contains("pistol") || id.Contains("blade") || id.Contains("sword") || id.Contains("shotgun") || id.Contains("smg"))
+ {
+ return "Weapon";
+ }
+
+ return "Unknown";
+ }
+
+ ///
+ /// Merge rows that share the same TweakDB id across different equipment views (e.g. Weapon vs WeaponWheel).
+ /// Canonical row = lowest priority number; raw list unchanged.
+ ///
+ public static IReadOnlyList DedupeEquippedSlots(IReadOnlyList raw)
+ {
+ return raw
+ .GroupBy(r => r.TweakDbIdUlong, StringComparer.Ordinal)
+ .Select(g =>
+ {
+ var ordered = g.OrderBy(r => GetCanonicalLoadoutPriority(r.AreaTypeEnum)).ToList();
+ var best = ordered[0];
+ var suppressed = ordered.Skip(1).Select(r => r.AreaTypeEnum).Distinct(StringComparer.Ordinal).ToList();
+ return new EquippedSlotNormalizedDto
+ {
+ AreaTypeLabel = best.AreaTypeLabel,
+ AreaTypeEnum = best.AreaTypeEnum,
+ TweakDbIdUlong = best.TweakDbIdUlong,
+ ItemIdResolved = best.ItemIdResolved,
+ DuplicateCount = g.Count(),
+ SuppressedAreaTypeEnums = suppressed,
+ };
+ })
+ .ToList();
+ }
+
+ public static IReadOnlyList DedupeCyberwareSlots(IReadOnlyList raw)
+ {
+ return raw
+ .GroupBy(r => r.TweakDbIdUlong, StringComparer.Ordinal)
+ .Select(g =>
+ {
+ var ordered = g.OrderBy(r => GetCyberwareCanonicalPriority(r.AreaTypeEnum)).ToList();
+ var best = ordered[0];
+ var suppressed = ordered.Skip(1).Select(r => r.AreaTypeEnum).Distinct(StringComparer.Ordinal).ToList();
+ return new CyberwareSlotNormalizedDto
+ {
+ AreaTypeEnum = best.AreaTypeEnum,
+ TweakDbIdUlong = best.TweakDbIdUlong,
+ ItemIdResolved = best.ItemIdResolved,
+ DuplicateCount = g.Count(),
+ SuppressedAreaTypeEnums = suppressed,
+ };
+ })
+ .ToList();
+ }
+
+ /// Lower = preferred primary slot when the same id appears under multiple area enums.
+ private static int GetCanonicalLoadoutPriority(string areaEnum)
+ {
+ return areaEnum switch
+ {
+ "Weapon" => 10,
+ "WeaponLeft" => 11,
+ "WeaponHeavy" => 12,
+ "VDefaultHandgun" => 13,
+ "LeftArm" => 20,
+ "RightArm" => 21,
+ "HandsCW" => 30,
+ "OuterChest" => 40,
+ "InnerChest" => 41,
+ "Legs" => 42,
+ "Feet" => 43,
+ "Head" => 44,
+ "Face" => 45,
+ "QuickSlot" => 100,
+ "QuickWheel" => 110,
+ "WeaponWheel" => 200,
+ "Gadget" => 120,
+ "Outfit" => 130,
+ _ => 150,
+ };
+ }
+
+ private static int GetCyberwareCanonicalPriority(string areaEnum)
+ {
+ return areaEnum switch
+ {
+ "FrontalCortexCW" => 0,
+ "NervousSystemCW" => 2,
+ "CardiovascularSystemCW" => 4,
+ "ImmuneSystemCW" => 6,
+ "IntegumentarySystemCW" => 8,
+ "MusculoskeletalSystemCW" => 10,
+ "EyesCW" => 20,
+ "HandsCW" => 30,
+ "ArmsCW" => 32,
+ "LegsCW" => 34,
+ "AbilityCW" => 40,
+ "SystemReplacementCW" => 50,
+ "CyberwareWheel" => 200,
+ "PersonalLink" => 60,
+ "Splinter" => 70,
+ "SilverhandArm" => 80,
+ _ => 100,
+ };
+ }
+
+ public static IReadOnlyList FilterNamedNonZero(IReadOnlyList all) =>
+ all.Where(f => f.Name != null && f.Value != 0).ToList();
+
+ public static QuestFactSummaryDto SummarizeFacts(IReadOnlyList all)
+ {
+ var withName = 0;
+ var nz = 0;
+ var z = 0;
+ foreach (var f in all)
+ {
+ if (f.Name != null)
+ {
+ withName++;
+ }
+
+ if (f.Value != 0)
+ {
+ nz++;
+ }
+ else
+ {
+ z++;
+ }
+ }
+
+ var nnz = all.Count(f => f.Name != null && f.Value != 0);
+ return new QuestFactSummaryDto
+ {
+ Total = all.Count,
+ WithCatalogName = withName,
+ NonZeroValue = nz,
+ ZeroValue = z,
+ NamedNonZero = nnz,
+ };
+ }
+
+ public static QuestSignalSummaryDto SummarizeQuestSignals(IReadOnlyList all)
+ {
+ var named = all.Where(f => f.Name != null).Select(f => f.Name!).ToList();
+ var qPrefix = 0;
+ var done = 0;
+ var failed = 0;
+ var ep1 = 0;
+ foreach (var n in named)
+ {
+ var ln = n.ToLowerInvariant();
+ if (ln.StartsWith("q", StringComparison.Ordinal) || ln.StartsWith("#q", StringComparison.Ordinal) || ln.StartsWith("sq_", StringComparison.Ordinal))
+ {
+ qPrefix++;
+ }
+
+ if (ln.Contains("done", StringComparison.Ordinal))
+ {
+ done++;
+ }
+
+ if (ln.Contains("fail", StringComparison.Ordinal))
+ {
+ failed++;
+ }
+
+ if (ln.Contains("ep1", StringComparison.Ordinal) || ln.Contains("phantom", StringComparison.Ordinal))
+ {
+ ep1++;
+ }
+ }
+
+ var mainDone = 0;
+ var sideDone = 0;
+ var gigsDone = 0;
+ foreach (var f in all)
+ {
+ if (f.Name == null)
+ {
+ continue;
+ }
+
+ var key = NormalizeQuestFactNameKey(f.Name);
+ if (!QuestNameLooksCompleted(key))
+ {
+ continue;
+ }
+
+ if (key.StartsWith("mq", StringComparison.Ordinal))
+ {
+ mainDone++;
+ }
+ else if (key.StartsWith("sq", StringComparison.Ordinal))
+ {
+ sideDone++;
+ }
+ else if (key.StartsWith("ma_", StringComparison.Ordinal) || key.StartsWith("sts_", StringComparison.Ordinal))
+ {
+ gigsDone++;
+ }
+ }
+
+ return new QuestSignalSummaryDto
+ {
+ NamedFactsMatchingQuestPrefix = qPrefix,
+ NamedFactsContainingDone = done,
+ NamedFactsContainingFailed = failed,
+ NamedFactsEp1Hint = ep1,
+ MainQuestCompleted = mainDone,
+ SideQuestCompleted = sideDone,
+ GigsCompleted = gigsDone,
+ };
+ }
+
+ private static string NormalizeQuestFactNameKey(string name)
+ {
+ var s = name.Trim();
+ if (s.Length > 0 && s[0] == '#')
+ {
+ s = s.Substring(1);
+ }
+
+ return s.Trim().ToLowerInvariant();
+ }
+
+ private static bool QuestNameLooksCompleted(string normalizedLower)
+ {
+ return normalizedLower.Contains("_done", StringComparison.Ordinal)
+ || normalizedLower.Contains("_finished", StringComparison.Ordinal);
+ }
+
+ public static IReadOnlyList BuildTags(int? level, int? streetCred, CoreAttributesDto core, int dedupedCyberwareSlotCount)
+ {
+ var tags = new List();
+ var lv = level ?? 0;
+ var sc = streetCred ?? 0;
+ if (lv >= 40)
+ {
+ tags.Add("level_cap_focus");
+ }
+ else if (lv >= 25)
+ {
+ tags.Add("mid_level");
+ }
+
+ if (sc >= 50)
+ {
+ tags.Add("high_street_cred");
+ }
+
+ if ((core.Intelligence ?? 0) >= 12)
+ {
+ tags.Add("int_focus");
+ }
+
+ if ((core.Cool ?? 0) >= 12)
+ {
+ tags.Add("cool_focus");
+ }
+
+ if ((core.Body ?? 0) >= 12)
+ {
+ tags.Add("body_focus");
+ }
+
+ if ((core.Reflexes ?? 0) >= 12)
+ {
+ tags.Add("reflex_focus");
+ }
+
+ if ((core.Technical ?? 0) >= 12)
+ {
+ tags.Add("tech_focus");
+ }
+
+ if (dedupedCyberwareSlotCount >= 12)
+ {
+ tags.Add("heavy_cyberware");
+ }
+
+ return tags;
+ }
+
+ public static string? ProgressionStageFromLevel(int? level)
+ {
+ if (level == null)
+ {
+ return null;
+ }
+
+ var l = level.Value;
+ if (l < 15)
+ {
+ return "early";
+ }
+
+ if (l < 30)
+ {
+ return "mid";
+ }
+
+ if (l < 45)
+ {
+ return "late";
+ }
+
+ return "endgame";
+ }
+
+ public static DerivedCompletionDto SummarizeCompletionMetrics(IReadOnlyList all)
+ {
+ var mainC = 0;
+ var mainP = 0;
+ var sideC = 0;
+ var sideP = 0;
+ var gigC = 0;
+ var gigP = 0;
+ foreach (var f in all)
+ {
+ if (f.Name == null)
+ {
+ continue;
+ }
+
+ var key = NormalizeQuestFactNameKey(f.Name);
+ var kind = CompletionQuestKind(key);
+ if (kind == 0)
+ {
+ continue;
+ }
+
+ if (QuestNameLooksCompleted(key))
+ {
+ switch (kind)
+ {
+ case 1: mainC++; break;
+ case 2: sideC++; break;
+ case 3: gigC++; break;
+ }
+ }
+ else if (QuestNameLooksInProgress(key))
+ {
+ switch (kind)
+ {
+ case 1: mainP++; break;
+ case 2: sideP++; break;
+ case 3: gigP++; break;
+ }
+ }
+ }
+
+ return new DerivedCompletionDto
+ {
+ MainQuest = new CompletionQuestBucketDto { Completed = mainC, InProgress = mainP },
+ SideQuest = new CompletionQuestBucketDto { Completed = sideC, InProgress = sideP },
+ Gigs = new CompletionQuestBucketDto { Completed = gigC, InProgress = gigP },
+ InProgressInterpretation = "approximate",
+ InProgressConfidence = "low",
+ CompletionInterpretation = "counts_reflect_detected_flags_not_full_quest_set",
+ CompletionConfidence = "low",
+ HasActiveQuestSignals = mainP + sideP + gigP > 0,
+ };
+ }
+
+ public static DerivedExplorationDto SummarizeExploration(IReadOnlyList fastTravelPoints)
+ {
+ // Real saves often expose more fast-travel rows than the old fixed denominator, which saturated a literal
+ // ratio at 1.0 and read like map completion. Use a soft bounded curve so the field stays a progression proxy.
+ var n = fastTravelPoints.Count;
+ const int proxyReferenceCount = 200;
+ var proxy = Math.Sqrt(Math.Min(1.0, n / (double)proxyReferenceCount));
+ var progressProxy = Math.Round(proxy, 3);
+ return new DerivedExplorationDto
+ {
+ ProgressProxy = progressProxy,
+ FastTravelPointCount = n,
+ ProxyReferenceCount = proxyReferenceCount,
+ Interpretation = "fast_travel_unlock_progress_proxy",
+ Confidence = "low",
+ };
+ }
+
+ private static int CompletionQuestKind(string normalizedLower)
+ {
+ if (normalizedLower.StartsWith("mq", StringComparison.Ordinal))
+ {
+ return 1;
+ }
+
+ if (normalizedLower.StartsWith("sq", StringComparison.Ordinal))
+ {
+ return 2;
+ }
+
+ if (normalizedLower.StartsWith("ma_", StringComparison.Ordinal)
+ || normalizedLower.StartsWith("sts_", StringComparison.Ordinal))
+ {
+ return 3;
+ }
+
+ return 0;
+ }
+
+ private static bool QuestNameLooksInProgress(string normalizedLower)
+ {
+ return normalizedLower.Contains("_active", StringComparison.Ordinal)
+ || normalizedLower.Contains("_start", StringComparison.Ordinal);
+ }
+
+ public static PhantomLibertySignalsDto SummarizePhantomLiberty(
+ IReadOnlyList all,
+ IReadOnlyList fastTravel,
+ QuestSignalSummaryDto questSignals)
+ {
+ var progressionSignals = 0;
+ foreach (var f in all)
+ {
+ if (f.Name == null)
+ {
+ continue;
+ }
+
+ var ln = f.Name.ToLowerInvariant();
+ if (ln.Contains("ep1", StringComparison.Ordinal)
+ || ln.Contains("phantom", StringComparison.Ordinal)
+ || ln.Contains("dogtown", StringComparison.Ordinal))
+ {
+ progressionSignals++;
+ }
+ }
+
+ var ftEp1 = 0;
+ foreach (var p in fastTravel)
+ {
+ if (p.IsEp1)
+ {
+ ftEp1++;
+ }
+ }
+
+ var detected = progressionSignals > 0 || ftEp1 > 0 || questSignals.NamedFactsEp1Hint > 0;
+ var likelyStarted = detected;
+ var level = "none";
+ if (detected)
+ {
+ if (ftEp1 == 0)
+ {
+ if (progressionSignals >= 18)
+ {
+ level = "mid";
+ }
+ else if (progressionSignals >= 5 || questSignals.NamedFactsEp1Hint >= 3)
+ {
+ level = "early";
+ }
+ else
+ {
+ level = "unknown";
+ }
+ }
+ else if (ftEp1 >= 3 || progressionSignals >= 12)
+ {
+ level = "mid";
+ }
+ else
+ {
+ level = "early";
+ }
+ }
+
+ string confidence;
+ if (!detected)
+ {
+ confidence = "low";
+ }
+ else if (ftEp1 > 0 && progressionSignals >= 5)
+ {
+ confidence = "high";
+ }
+ else if (ftEp1 > 0 || progressionSignals >= 5 || questSignals.NamedFactsEp1Hint >= 3)
+ {
+ confidence = "medium";
+ }
+ else
+ {
+ confidence = "low";
+ }
+
+ return new PhantomLibertySignalsDto
+ {
+ Detected = detected,
+ ProgressionSignals = progressionSignals,
+ FastTravelUnlocked = ftEp1,
+ LikelyStarted = likelyStarted,
+ LikelyProgressLevel = level,
+ Interpretation = "heuristic_signal_based",
+ Confidence = confidence,
+ };
+ }
+
+ public static DerivedExpansionDto SummarizeExpansion(
+ IReadOnlyList all,
+ IReadOnlyList fastTravel,
+ QuestSignalSummaryDto questSignals)
+ {
+ return new DerivedExpansionDto
+ {
+ PhantomLiberty = SummarizePhantomLiberty(all, fastTravel, questSignals),
+ };
+ }
+
+ public static DerivedInventoryInsightsDto SummarizeInventoryInsights(
+ IReadOnlyList rows,
+ int? level,
+ ulong? moneyQuantity)
+ {
+ // Capability-style counts use first row per TweakDbIdUlong so duplicate stacks do not inflate programs/OS/iconic tallies.
+ var dedupedForCapability = FirstRowPerTweakDbId(rows);
+
+ var qhProg = 0;
+ var qhMat = 0;
+ var osCount = 0;
+ var iconic = 0;
+ var craftingStacks = 0;
+ foreach (var row in dedupedForCapability)
+ {
+ var id = InventoryItemIdLower(row);
+ if (id.Contains("iconic", StringComparison.Ordinal))
+ {
+ iconic++;
+ }
+
+ if (LooksLikeOperatingSystemItem(id))
+ {
+ osCount++;
+ }
+
+ var mat = LooksLikeQuickhackMaterial(id);
+ if (mat)
+ {
+ qhMat++;
+ }
+ else if (LooksLikeQuickhackProgram(id, row.InferredCategory))
+ {
+ qhProg++;
+ }
+ }
+
+ // Wealth thresholds: still count crafting category rows (stack-level), not distinct item types.
+ foreach (var row in rows)
+ {
+ if (string.Equals(row.InferredCategory, "Crafting", StringComparison.Ordinal))
+ {
+ craftingStacks++;
+ }
+ }
+
+ var hasQh = qhProg > 0 || qhMat > 0;
+ var money = moneyQuantity ?? 0UL;
+ var lv = level ?? 0;
+ var materialWealth = "low";
+ if (craftingStacks >= 22 || money > 180000UL)
+ {
+ materialWealth = "high";
+ }
+ else if (craftingStacks >= 9 || money > 45000UL)
+ {
+ materialWealth = "medium";
+ }
+
+ var upgradeReadiness = "low";
+ if (lv >= 32 && craftingStacks >= 14 && money > 90000UL)
+ {
+ upgradeReadiness = "high";
+ }
+ else if (lv >= 18 && (craftingStacks >= 7 || money > 35000UL))
+ {
+ upgradeReadiness = "medium";
+ }
+
+ var buildFocus = "generalist";
+ if (qhProg >= 3 && osCount >= 1)
+ {
+ buildFocus = "netrunner_capable";
+ }
+ else if (craftingStacks >= 10 && money > 35000UL)
+ {
+ buildFocus = "crafting_ready";
+ }
+ else if (iconic >= 3)
+ {
+ buildFocus = "gear_progression_focus";
+ }
+
+ return new DerivedInventoryInsightsDto
+ {
+ HasQuickhacks = hasQh,
+ QuickhackProgramCount = qhProg,
+ QuickhackMaterialCount = qhMat,
+ HasMultipleOperatingSystems = osCount >= 2,
+ OperatingSystemCount = osCount,
+ IconicItemCount = iconic,
+ MaterialWealth = materialWealth,
+ UpgradeReadiness = upgradeReadiness,
+ Interpretation = "inventory_signal_based",
+ Confidence = "medium",
+ BuildCapabilityFocus = buildFocus,
+ };
+ }
+
+ /// First occurrence per (order-preserving).
+ private static List FirstRowPerTweakDbId(IReadOnlyList rows)
+ {
+ var seen = new HashSet(StringComparer.Ordinal);
+ var list = new List(rows.Count);
+ foreach (var row in rows)
+ {
+ if (seen.Add(row.TweakDbIdUlong))
+ {
+ list.Add(row);
+ }
+ }
+
+ return list;
+ }
+
+ private static string InventoryItemIdLower(InventoryItemNormalizedDto row)
+ {
+ return (row.ItemIdResolved ?? row.ItemIdPresentation).ToLowerInvariant();
+ }
+
+ private static bool LooksLikeQuickhackMaterial(string idLower)
+ {
+ if (!idLower.Contains("quickhack", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ return idLower.Contains("fragment", StringComparison.Ordinal)
+ || idLower.Contains("crafting", StringComparison.Ordinal)
+ || (idLower.Contains("shard", StringComparison.Ordinal) && !idLower.Contains("program", StringComparison.Ordinal));
+ }
+
+ /// Quickhack daemon programs (inventory), including *Program records that may not use InferredCategory Quickhack.
+ private static bool LooksLikeQuickhackProgram(string idLower, string inferredCategory)
+ {
+ if (LooksLikeQuickhackMaterial(idLower))
+ {
+ return false;
+ }
+
+ if (string.Equals(inferredCategory, "Quickhack", StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ if (!idLower.Contains("program", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (idLower.EndsWith("program", StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ if (idLower.Contains("lvl", StringComparison.Ordinal) && idLower.Contains("program", StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool LooksLikeOperatingSystemItem(string idLower)
+ {
+ if (idLower.Contains("fragment", StringComparison.Ordinal)
+ || idLower.Contains("shard", StringComparison.Ordinal)
+ || idLower.Contains("cyberwarestatsshard", StringComparison.Ordinal)
+ || idLower.Contains("cyberwareupgradeshard", StringComparison.Ordinal)
+ || idLower.Contains("upgradeshard", StringComparison.Ordinal)
+ || idLower.Contains("upgrade_shard", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (idLower.Contains("sandevistan", StringComparison.Ordinal)
+ || idLower.Contains("berserk", StringComparison.Ordinal)
+ || idLower.Contains("cyberdeck", StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ return idLower.Contains("operating_system", StringComparison.Ordinal)
+ || idLower.Contains("operatingsystem", StringComparison.Ordinal);
+ }
+
+ public static DerivedEquipmentMaturityDto SummarizeEquipmentMaturity(
+ int? level,
+ ulong? moneyQuantity,
+ IReadOnlyList invRows,
+ IReadOnlyList slotsDeduped,
+ IReadOnlyList cyberDeduped,
+ int cyberwareSlotRowCount)
+ {
+ var scores = new List();
+ foreach (var s in slotsDeduped)
+ {
+ var sc = TierScoreFromItemText(s.ItemIdResolved ?? "");
+ if (sc != null)
+ {
+ scores.Add(sc.Value);
+ }
+ }
+
+ foreach (var c in cyberDeduped)
+ {
+ var sc = TierScoreFromItemText(c.ItemIdResolved ?? "");
+ if (sc != null)
+ {
+ scores.Add(sc.Value);
+ }
+ }
+
+ var avgTier = "unknown";
+ if (scores.Count > 0)
+ {
+ var a = scores.Average();
+ avgTier = a >= 4.5 ? "legendary" : a >= 3.5 ? "epic" : a >= 2.5 ? "rare" : a >= 1.5 ? "uncommon" : "common";
+ }
+
+ var craftingLike = 0;
+ foreach (var r in invRows)
+ {
+ if (string.Equals(r.InferredCategory, "Crafting", StringComparison.Ordinal))
+ {
+ craftingLike++;
+ }
+ }
+
+ var lv = level ?? 0;
+ var money = moneyQuantity ?? 0;
+ var epicPlus = scores.Count(s => s >= 4);
+ var potential = "low";
+ if (lv >= 35 && money > 150000UL && craftingLike >= 12 && epicPlus >= 2)
+ {
+ potential = "high";
+ }
+ else if (lv >= 18 && (craftingLike >= 6 || money > 40000UL || epicPlus >= 1))
+ {
+ potential = "medium";
+ }
+
+ var possible = CyberwareSlotsPossible;
+ var util = possible <= 0
+ ? 0.0
+ : Math.Min(1.0, cyberwareSlotRowCount / (double)possible);
+ var utilRounded = Math.Round(util, 3);
+ var investment = utilRounded < 0.30 ? "low" : utilRounded < 0.70 ? "medium" : "high";
+
+ return new DerivedEquipmentMaturityDto
+ {
+ AverageTier = avgTier,
+ CyberwareSlotsUsed = cyberwareSlotRowCount,
+ CyberwareSlotsPossible = possible,
+ CyberwareUtilizationRatio = utilRounded,
+ UpgradePotential = potential,
+ CyberwareInvestmentLevel = investment,
+ Interpretation = "slot_utilization_based",
+ Confidence = "medium",
+ };
+ }
+
+ private static int? TierScoreFromItemText(string text)
+ {
+ var t = text.ToLowerInvariant();
+ if (t.Contains("legendary", StringComparison.Ordinal))
+ {
+ return 5;
+ }
+
+ if (t.Contains("epic", StringComparison.Ordinal))
+ {
+ return 4;
+ }
+
+ if (t.Contains("uncommon", StringComparison.Ordinal))
+ {
+ return 2;
+ }
+
+ if (t.Contains("rare", StringComparison.Ordinal))
+ {
+ return 3;
+ }
+
+ if (t.Contains("common", StringComparison.Ordinal))
+ {
+ return 1;
+ }
+
+ return null;
+ }
+
+ public static DerivedBuildProfileDto SummarizeBuildProfile(
+ CoreAttributesDto core,
+ IReadOnlyList buildTags,
+ IReadOnlyList cyberInstances,
+ DerivedInventoryInsightsDto inventoryInsights)
+ {
+ var signals = new List();
+ var intel = core.Intelligence ?? 0;
+ var body = core.Body ?? 0;
+ var cool = core.Cool ?? 0;
+ var tech = core.Technical ?? 0;
+ var reflex = core.Reflexes ?? 0;
+
+ if (intel >= 12)
+ {
+ signals.Add("high intelligence");
+ }
+
+ if (body >= 12)
+ {
+ signals.Add("body >= 12");
+ }
+
+ if (cool >= 12)
+ {
+ signals.Add("cool >= 12");
+ }
+
+ if (tech >= 12)
+ {
+ signals.Add("technical >= 12");
+ }
+
+ if (inventoryInsights.HasQuickhacks)
+ {
+ signals.Add("quickhack inventory present");
+ }
+
+ var cyberdeckEquipped = false;
+ foreach (var c in cyberInstances)
+ {
+ var id = (c.ItemIdResolved ?? c.ItemIdPresentation).ToLowerInvariant();
+ if (id.Contains("cyberdeck", StringComparison.Ordinal)
+ || id.Contains("frontalcortex", StringComparison.Ordinal) && id.Contains("deck", StringComparison.Ordinal))
+ {
+ cyberdeckEquipped = true;
+ break;
+ }
+ }
+
+ if (cyberdeckEquipped)
+ {
+ signals.Add("cyberdeck equipped");
+ }
+
+ foreach (var t in buildTags.Take(4))
+ {
+ signals.Add("tag: " + t);
+ }
+
+ var primary = "hybrid-generalist";
+ if (intel >= 14 && tech >= 11 && (inventoryInsights.HasQuickhacks || cyberdeckEquipped))
+ {
+ primary = "netrunner-tech";
+ }
+ else if (body >= 14 && tech >= 11)
+ {
+ primary = "body-tech";
+ }
+ else if (cool >= 14 && reflex >= 10)
+ {
+ primary = "stealth-cool";
+ }
+
+ var secondary = PickSecondaryArchetype(primary, intel, body, cool, tech, reflex, inventoryInsights.HasQuickhacks, cyberdeckEquipped);
+
+ var confidence = "low";
+ if (signals.Count >= 5)
+ {
+ confidence = "high";
+ }
+ else if (signals.Count >= 2)
+ {
+ confidence = "medium";
+ }
+
+ return new DerivedBuildProfileDto
+ {
+ Primary = primary,
+ Secondary = secondary,
+ Confidence = confidence,
+ Signals = signals,
+ };
+ }
+
+ private static string PickSecondaryArchetype(
+ string primary,
+ int intel,
+ int body,
+ int cool,
+ int tech,
+ int reflex,
+ bool hasQuickhacks,
+ bool cyberdeckEquipped)
+ {
+ if (primary == "netrunner-tech")
+ {
+ if (body >= 12 && body >= intel)
+ {
+ return "body-tech";
+ }
+
+ return cool >= 12 ? "stealth-cool" : "hybrid-generalist";
+ }
+
+ if (primary == "body-tech")
+ {
+ if (intel >= 12 && (hasQuickhacks || cyberdeckEquipped))
+ {
+ return "netrunner-tech";
+ }
+
+ return cool >= 12 ? "stealth-cool" : "hybrid-generalist";
+ }
+
+ if (primary == "stealth-cool")
+ {
+ if (intel >= 12 && (hasQuickhacks || cyberdeckEquipped))
+ {
+ return "netrunner-tech";
+ }
+
+ return body >= 12 ? "body-tech" : "hybrid-generalist";
+ }
+
+ if (intel >= 12 && tech >= 10 && (hasQuickhacks || cyberdeckEquipped))
+ {
+ return "netrunner-tech";
+ }
+
+ if (body >= 12 && tech >= 10)
+ {
+ return "body-tech";
+ }
+
+ if (cool >= 12 && reflex >= 9)
+ {
+ return "stealth-cool";
+ }
+
+ return "hybrid-generalist";
+ }
+}
diff --git a/CP2077SaveExporter/JsonExporter.cs b/CP2077SaveExporter/JsonExporter.cs
new file mode 100644
index 0000000..0d0f9c9
--- /dev/null
+++ b/CP2077SaveExporter/JsonExporter.cs
@@ -0,0 +1,19 @@
+using System.Text.Json;
+
+namespace CP2077SaveExporter;
+
+/// Read-only JSON serialization to disk.
+public static class JsonExporter
+{
+ public static async Task WriteAsync(string path, T value, JsonSerializerOptions options, CancellationToken cancellationToken = default)
+ {
+ var dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir))
+ {
+ Directory.CreateDirectory(dir);
+ }
+
+ var json = JsonSerializer.Serialize(value, options);
+ await File.WriteAllTextAsync(path, json, cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/CP2077SaveExporter/Models.cs b/CP2077SaveExporter/Models.cs
new file mode 100644
index 0000000..9df48fe
--- /dev/null
+++ b/CP2077SaveExporter/Models.cs
@@ -0,0 +1,441 @@
+namespace CP2077SaveExporter;
+
+// -------------------------------------------------------------------------
+// Root
+// -------------------------------------------------------------------------
+
+/// Full export: header plus raw / normalized / derived analysis layers.
+public sealed class ExportSnapshot
+{
+ public SaveHeaderDto Header { get; init; } = new();
+ public RawSectionDto Raw { get; init; } = new();
+ public NormalizedSectionDto Normalized { get; init; } = new();
+ public DerivedSectionDto Derived { get; init; } = new();
+}
+
+/// Raw-only JSON projection: header and raw sections (no normalized / derived).
+public sealed class RawExportSnapshot
+{
+ public SaveHeaderDto Header { get; init; } = new();
+ public RawSectionDto Raw { get; init; } = new();
+}
+
+/// Normalized + derived layers only (no header / raw).
+public sealed class InsightsExportSnapshot
+{
+ public required object Normalized { get; init; }
+ public required object Derived { get; init; }
+}
+
+public sealed class SaveHeaderDto
+{
+ public uint SaveVersion { get; init; }
+ public uint GameVersion { get; init; }
+ public string GameDefPath { get; init; } = "";
+ public ulong TimeStamp { get; init; }
+ public uint ArchiveVersion { get; init; }
+}
+
+// -------------------------------------------------------------------------
+// Raw (verbatim IDs, hashes, unmerged lists)
+// -------------------------------------------------------------------------
+
+public sealed class RawSectionDto
+{
+ public int? Level { get; init; }
+ public int? StreetCred { get; init; }
+ public MoneyDto Money { get; init; } = new();
+
+ /// All player-development attribute rows as stored (stat enum name + value).
+ public IReadOnlyList AttributesFlat { get; init; } = Array.Empty();
+
+ public InventoryRawDto Inventory { get; init; } = new();
+ public EquipmentRawDto Equipment { get; init; } = new();
+
+ public IReadOnlyList QuestFactsAll { get; init; } = Array.Empty();
+
+ public IReadOnlyList FastTravelPoints { get; init; } = Array.Empty();
+}
+
+public sealed class AttributeFlatDto
+{
+ public string StatType { get; init; } = "";
+ public int Value { get; init; }
+}
+
+public sealed class InventoryRawDto
+{
+ public InventorySummaryDto Summary { get; init; } = new();
+ public IReadOnlyList ItemRows { get; init; } = Array.Empty();
+}
+
+public sealed class InventorySummaryDto
+{
+ public int SubInventoryCount { get; init; }
+ public int TotalItemStacks { get; init; }
+ public IReadOnlyList SubInventories { get; init; } = Array.Empty();
+}
+
+public sealed class SubInventorySummaryDto
+{
+ public string InventoryId { get; init; } = "";
+ public int ItemCount { get; init; }
+}
+
+/// One inventory stack as read from save (hashes preserved).
+public sealed class InventoryItemRawDto
+{
+ public string InventoryId { get; init; } = "";
+ /// Full 64-bit TweakDB id value (same as game).
+ public string TweakDbIdUlong { get; init; } = "";
+ public string? ItemIdResolved { get; init; }
+ /// RED string form of TweakDBID (often includes hash and length).
+ public string ItemIdPresentation { get; init; } = "";
+ public ulong Quantity { get; init; }
+ public string Flags { get; init; } = "";
+ public uint CreationTime { get; init; }
+ public string ItemStructure { get; init; } = "";
+}
+
+public sealed class EquipmentRawDto
+{
+ public IReadOnlyList AllSlots { get; init; } = Array.Empty();
+ public IReadOnlyList CyberwareSlots { get; init; } = Array.Empty();
+}
+
+public sealed class EquippedSlotRawDto
+{
+ public string AreaTypeLabel { get; init; } = "";
+ public string AreaTypeEnum { get; init; } = "";
+ public string TweakDbIdUlong { get; init; } = "";
+ public string? ItemIdResolved { get; init; }
+ public string ItemIdPresentation { get; init; } = "";
+}
+
+public sealed class CyberwareSlotRawDto
+{
+ public string AreaTypeEnum { get; init; } = "";
+ public string TweakDbIdUlong { get; init; } = "";
+ public string? ItemIdResolved { get; init; }
+ public string ItemIdPresentation { get; init; } = "";
+}
+
+public sealed class QuestFactRawDto
+{
+ public uint FactHash { get; init; }
+ public string FactHashHex { get; init; } = "";
+ public string? Name { get; init; }
+ public uint Value { get; init; }
+}
+
+public sealed class FastTravelPointRawDto
+{
+ public string? PointRecordResolved { get; init; }
+ public string PointRecordTweakDbUlong { get; init; } = "";
+ public string? MarkerRefResolved { get; init; }
+ public string MarkerRefUlong { get; init; } = "";
+ public bool IsEp1 { get; init; }
+}
+
+public sealed class MoneyDto
+{
+ public ulong? InventoryItemsMoneyQuantity { get; init; }
+ public float? StatPoolsSystemCurrency { get; init; }
+}
+
+// -------------------------------------------------------------------------
+// Normalized (cleaner shapes, categories, deduped equipment)
+// -------------------------------------------------------------------------
+
+public sealed class NormalizedSectionDto
+{
+ public CoreAttributesDto CoreAttributes { get; init; } = new();
+ /// Non-core attribute stats (enum name -> points).
+ public IReadOnlyDictionary OtherStats { get; init; } =
+ new Dictionary();
+
+ public InventoryNormalizedDto Inventory { get; init; } = new();
+ public EquipmentNormalizedDto Equipment { get; init; } = new();
+
+ public IReadOnlyList QuestFactsNamedNonZero { get; init; } = Array.Empty();
+}
+
+/// Gameplay-oriented labels; raw still uses engine enum names (Strength, TechnicalAbility).
+public sealed class CoreAttributesDto
+{
+ public int? Body { get; init; }
+ public int? Reflexes { get; init; }
+ public int? Intelligence { get; init; }
+ public int? Technical { get; init; }
+ public int? Cool { get; init; }
+}
+
+public sealed class InventoryNormalizedDto
+{
+ public InventorySummaryDto Summary { get; init; } = new();
+ public IReadOnlyList ItemRows { get; init; } = Array.Empty();
+}
+
+public sealed class InventoryItemNormalizedDto
+{
+ public string InventoryId { get; init; } = "";
+ public string TweakDbIdUlong { get; init; } = "";
+ public string? ItemIdResolved { get; init; }
+ public string ItemIdPresentation { get; init; } = "";
+ public ulong Quantity { get; init; }
+ public string InferredCategory { get; init; } = "";
+ /// True when this stack's TweakDB id matches any equipped weapon/clothing slot or cyberware slot (best-effort; same base id as equipped).
+ public bool AppearsEquipped { get; init; }
+
+ /// tweakdbid_match | not_matched — see .
+ public string AppearsEquippedReason { get; init; } = "not_matched";
+}
+
+public sealed class EquipmentNormalizedDto
+{
+ /// Equipped items with duplicate (area + item) rows merged.
+ public IReadOnlyList SlotsDeduped { get; init; } = Array.Empty();
+ public IReadOnlyList CyberwareDeduped { get; init; } = Array.Empty();
+
+ /// One entry per equipped slot row (no TweakDB id merge).
+ public IReadOnlyList SlotInstances { get; init; } = Array.Empty();
+
+ /// One entry per cyberware slot row (no TweakDB id merge).
+ public IReadOnlyList CyberwareInstances { get; init; } = Array.Empty();
+}
+
+/// Slot-faithful loadout row (normalized copy of raw equipped slot).
+public sealed class EquippedSlotInstanceDto
+{
+ public string AreaTypeLabel { get; init; } = "";
+ public string AreaTypeEnum { get; init; } = "";
+ public string TweakDbIdUlong { get; init; } = "";
+ public string? ItemIdResolved { get; init; }
+ public string ItemIdPresentation { get; init; } = "";
+}
+
+/// Slot-faithful cyberware row (normalized copy of raw cyberware slot).
+public sealed class CyberwareSlotInstanceDto
+{
+ public string AreaTypeEnum { get; init; } = "";
+ public string TweakDbIdUlong { get; init; } = "";
+ public string? ItemIdResolved { get; init; }
+ public string ItemIdPresentation { get; init; } = "";
+}
+
+public sealed class EquippedSlotNormalizedDto
+{
+ public string AreaTypeLabel { get; init; } = "";
+ public string AreaTypeEnum { get; init; } = "";
+ public string TweakDbIdUlong { get; init; } = "";
+ public string? ItemIdResolved { get; init; }
+ /// How many raw rows were merged for this TweakDB id (cross-slot duplicates).
+ public int DuplicateCount { get; init; }
+ /// Non-canonical area enums merged into this row (e.g. WeaponWheel when Weapon was chosen).
+ public IReadOnlyList SuppressedAreaTypeEnums { get; init; } = Array.Empty();
+}
+
+public sealed class CyberwareSlotNormalizedDto
+{
+ public string AreaTypeEnum { get; init; } = "";
+ public string TweakDbIdUlong { get; init; } = "";
+ public string? ItemIdResolved { get; init; }
+ public int DuplicateCount { get; init; }
+ public IReadOnlyList SuppressedAreaTypeEnums { get; init; } = Array.Empty();
+}
+
+// -------------------------------------------------------------------------
+// Derived (heuristic tags, aggregates — not read directly from save bytes)
+// -------------------------------------------------------------------------
+
+public sealed class DerivedSectionDto
+{
+ public IReadOnlyList BuildTags { get; init; } = Array.Empty();
+ public string? ProgressionStage { get; init; }
+ public QuestFactSummaryDto QuestFactSummary { get; init; } = new();
+ public QuestSignalSummaryDto QuestSignals { get; init; } = new();
+ public DerivedInventorySummaryDto InventorySummary { get; init; } = new();
+ public DerivedEquipmentSummaryDto EquipmentSummary { get; init; } = new();
+
+ public DerivedCompletionDto Completion { get; init; } = new();
+ public DerivedExpansionDto Expansion { get; init; } = new();
+ public DerivedInventoryInsightsDto InventoryInsights { get; init; } = new();
+ public DerivedEquipmentMaturityDto EquipmentMaturity { get; init; } = new();
+ public DerivedBuildProfileDto BuildProfile { get; init; } = new();
+ public DerivedExplorationDto Exploration { get; init; } = new();
+}
+
+public sealed class DerivedCompletionDto
+{
+ public CompletionQuestBucketDto MainQuest { get; init; } = new();
+ public CompletionQuestBucketDto SideQuest { get; init; } = new();
+ public CompletionQuestBucketDto Gigs { get; init; } = new();
+
+ /// inProgress counts are substring-based heuristics, not exact game state.
+ public string InProgressInterpretation { get; init; } = "approximate";
+
+ /// low | medium | high — confidence in inProgress totals only.
+ public string InProgressConfidence { get; init; } = "low";
+
+ /// Heuristic AI-facing note: completed/in-progress tallies are signal counts from detected fact-name patterns, not a full quest catalog.
+ public string CompletionInterpretation { get; init; } = "";
+
+ /// Heuristic AI-facing confidence for how literally completion buckets should be read (not statistical certainty).
+ public string CompletionConfidence { get; init; } = "low";
+
+ /// True when any main/side/gig bucket has a positive in-progress heuristic count (active/start style flags present).
+ public bool HasActiveQuestSignals { get; init; }
+}
+
+public sealed class CompletionQuestBucketDto
+{
+ public int Completed { get; init; }
+
+ ///
+ /// Counts facts whose names look in-progress (_active / _start). Many games reuse similar tokens;
+ /// treat as a loose signal, not an exact active-quest list.
+ ///
+ public int InProgress { get; init; }
+}
+
+public sealed class DerivedExpansionDto
+{
+ public PhantomLibertySignalsDto PhantomLiberty { get; init; } = new();
+}
+
+public sealed class PhantomLibertySignalsDto
+{
+ public bool Detected { get; init; }
+ public int ProgressionSignals { get; init; }
+ public int FastTravelUnlocked { get; init; }
+ public bool LikelyStarted { get; init; }
+ public string LikelyProgressLevel { get; init; } = "none";
+
+ /// Heuristic AI-facing label: expansion signals are inferred from quest/fact and fast-travel hints, not a direct EP1 progress readout.
+ public string Interpretation { get; init; } = "";
+
+ /// Heuristic AI-facing confidence (low | medium | high) from evidence strength, not proof of installation state.
+ public string Confidence { get; init; } = "low";
+}
+
+public sealed class DerivedInventoryInsightsDto
+{
+ public bool HasQuickhacks { get; init; }
+ public int QuickhackProgramCount { get; init; }
+ public int QuickhackMaterialCount { get; init; }
+ public bool HasMultipleOperatingSystems { get; init; }
+ public int OperatingSystemCount { get; init; }
+ public int IconicItemCount { get; init; }
+
+ /// low | medium | high — crafting stacks + cash heuristic.
+ public string MaterialWealth { get; init; } = "low";
+
+ /// low | medium | high — level + crafting + cash heuristic.
+ public string UpgradeReadiness { get; init; } = "low";
+
+ /// Heuristic AI-facing note: inventory-derived tags from item id/category signals, not build planner truth.
+ public string Interpretation { get; init; } = "";
+
+ /// Heuristic AI-facing confidence for inventory insight fields as a whole.
+ public string Confidence { get; init; } = "medium";
+
+ /// Heuristic AI-facing intent label from quickhack/OS/iconic/crafting/money signals (coarse archetype hint).
+ public string BuildCapabilityFocus { get; init; } = "";
+}
+
+public sealed class DerivedEquipmentMaturityDto
+{
+ public string AverageTier { get; init; } = "unknown";
+ public int CyberwareSlotsUsed { get; init; }
+
+ /// Body cyberware slot budget aligned with exporter equipment areas (not save-parsed).
+ public int CyberwareSlotsPossible { get; init; }
+
+ /// slotsUsed / slotsPossible, capped at 1.0.
+ public double CyberwareUtilizationRatio { get; init; }
+
+ public string UpgradePotential { get; init; } = "low";
+
+ /// Heuristic AI-facing bucket (low | medium | high) from slot fill ratio, not clinical assessment.
+ public string CyberwareInvestmentLevel { get; init; } = "";
+
+ /// Heuristic AI-facing note: maturity labels derive from slot utilization and item text tiers.
+ public string Interpretation { get; init; } = "";
+
+ /// Heuristic AI-facing confidence for cyberware utilization framing.
+ public string Confidence { get; init; } = "medium";
+}
+
+public sealed class DerivedBuildProfileDto
+{
+ public string Primary { get; init; } = "hybrid-generalist";
+ public string Secondary { get; init; } = "hybrid-generalist";
+
+ /// low | medium | high
+ public string Confidence { get; init; } = "low";
+
+ public IReadOnlyList Signals { get; init; } = Array.Empty();
+}
+
+public sealed class DerivedExplorationDto
+{
+ /// Bounded heuristic progression proxy from unlocked fast-travel count; not map completion.
+ public double ProgressProxy { get; init; }
+
+ /// Unlocked fast-travel nodes counted by exporter (raw list length).
+ public int FastTravelPointCount { get; init; }
+
+ /// Reference count used only for the bounded proxy formula; not canonical game truth.
+ public int ProxyReferenceCount { get; init; } = 200;
+
+ /// Heuristic AI-facing note: progress is inferred from fast-travel unlock volume, not actual area completion.
+ public string Interpretation { get; init; } = "";
+
+ /// Heuristic AI-facing confidence for exploration proxy fields.
+ public string Confidence { get; init; } = "low";
+}
+
+public sealed class DerivedInventorySummaryDto
+{
+ public int WeaponCount { get; init; }
+ public int CyberwareCount { get; init; }
+ public int ConsumableCount { get; init; }
+ public int ClothingCount { get; init; }
+ public int QuickhackCount { get; init; }
+ public int QuestItemCount { get; init; }
+ public int AmmoCount { get; init; }
+ /// Distinct TweakDB id values across inventory rows (one per item type id).
+ public int UniqueItemTypes { get; init; }
+}
+
+public sealed class DerivedEquipmentSummaryDto
+{
+ public int TotalEquippedWeapons { get; init; }
+ public int TotalEquippedCyberware { get; init; }
+}
+
+public sealed class QuestFactSummaryDto
+{
+ public int Total { get; init; }
+ public int WithCatalogName { get; init; }
+ public int NonZeroValue { get; init; }
+ public int ZeroValue { get; init; }
+ public int NamedNonZero { get; init; }
+}
+
+public sealed class QuestSignalSummaryDto
+{
+ public int NamedFactsMatchingQuestPrefix { get; init; }
+ public int NamedFactsContainingDone { get; init; }
+ public int NamedFactsContainingFailed { get; init; }
+ public int NamedFactsEp1Hint { get; init; }
+
+ /// Named facts: main-line style (prefix mq) and completion suffix.
+ public int MainQuestCompleted { get; init; }
+
+ /// Named facts: side-quest style (prefix sq) and completion suffix.
+ public int SideQuestCompleted { get; init; }
+
+ /// Named facts: open-world / gig style (ma_ or sts_) and completion suffix.
+ public int GigsCompleted { get; init; }
+}
diff --git a/CP2077SaveExporter/Program.cs b/CP2077SaveExporter/Program.cs
new file mode 100644
index 0000000..5b1c9ce
--- /dev/null
+++ b/CP2077SaveExporter/Program.cs
@@ -0,0 +1,354 @@
+using System.Text.Json;
+using WolvenKit.Common.Services;
+using WolvenKit.Core.Compression;
+using WolvenKit.RED4.Save.IO;
+using WolvenKit.RED4.TweakDB.Helper;
+
+namespace CP2077SaveExporter;
+
+internal static class Program
+{
+ private const int ExitOk = 0;
+ private const int ExitUsage = 1;
+ private const int ExitLoadError = 2;
+ private const int ExitInitError = 3;
+
+ private const string RawFileName = "save.raw.json";
+ private const string FullFileName = "save.full.json";
+ private const string InsightsFileName = "save.insights.json";
+
+ private enum ExportOutputMode
+ {
+ Raw,
+ Full,
+ Insights,
+ Split,
+ }
+
+ private static async Task Main(string[] args)
+ {
+ Console.Error.WriteLine("CP2077 read-only save exporter (WolvenKit-based).");
+
+ if (args.Length == 0)
+ {
+ PrintUsage();
+ return ExitUsage;
+ }
+
+ if (args.Length == 1 && args[0] is "-h" or "--help")
+ {
+ PrintUsage();
+ return ExitOk;
+ }
+
+ if (!TryParseArgs(
+ args,
+ out var savPath,
+ out var mode,
+ out var outputDirectory,
+ out var factsPathOverride,
+ out var parseError))
+ {
+ if (parseError != null)
+ {
+ Console.Error.WriteLine(parseError);
+ }
+
+ return ExitUsage;
+ }
+
+ // Match CP2077SaveEditor Form2: Oodle off for embedded kark decompression used by HashService / streams.
+ CompressionSettings.Get().UseOodle = false;
+
+ // Editor also calls ModManager.LoadTypes() (separate assembly) to register modded RED classes for modded saves.
+ // That path is not available here without referencing the editor; modded ScriptableSystems may fail to parse.
+
+ savPath = Path.GetFullPath(savPath);
+
+ if (!File.Exists(savPath))
+ {
+ Console.Error.WriteLine($"File not found: {savPath}");
+ return ExitLoadError;
+ }
+
+ HashService? hashService = null;
+ TweakDBStringHelper? tweakDbStrings = null;
+
+ try
+ {
+ // Older WolvenKit: HashService loads embedded pools synchronously in the constructor (no Task Loaded).
+ Console.Error.WriteLine("Initializing hash / string resolution (WolvenKit.Common)...");
+ hashService = new HashService();
+
+ Console.Error.WriteLine("Loading TweakDB string sidecar (optional CRC-based map; parallel to HashService pools)...");
+ using (var tweakStream = typeof(HashService).Assembly.GetManifestResourceStream("WolvenKit.Common.Resources.tweakdbstr.kark"))
+ {
+ if (tweakStream != null)
+ {
+ tweakDbStrings = new TweakDBStringHelper();
+ tweakDbStrings.LoadFromStream(tweakStream);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Initialization failed: {ex.Message}");
+ return ExitInitError;
+ }
+
+ Console.Error.WriteLine($"Reading save: {savPath}");
+ var (code, save) = SaveLoader.Load(savPath);
+ if (code != EFileReadErrorCodes.NoError || save == null)
+ {
+ Console.Error.WriteLine($"ReadFile failed: {code}");
+ return ExitLoadError;
+ }
+
+ IReadOnlyDictionary? knownFacts = null;
+ var factsPath = factsPathOverride != null
+ ? factsPathOverride
+ : Path.Combine(AppContext.BaseDirectory, "Facts.json");
+ if (File.Exists(factsPath))
+ {
+ try
+ {
+ var json = await File.ReadAllTextAsync(factsPath).ConfigureAwait(false);
+ knownFacts = TryParseFactsJsonObject(json);
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine("Warning: could not parse Facts.json; quest fact names will be omitted. " + ex.Message);
+ }
+ }
+ else if (factsPathOverride != null)
+ {
+ Console.Error.WriteLine(
+ $"Warning: Facts.json not found at '{factsPath}'; quest fact names will be hash-only.");
+ }
+ else
+ {
+ Console.Error.WriteLine("Warning: Facts.json not beside executable; quest fact names will be hash-only.");
+ }
+
+ _ = tweakDbStrings;
+
+ var snapshot = ProgressExtractor.Build(save, hashService!, knownFacts);
+
+ var jsonOptions = new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ };
+
+ var outDir = outputDirectory == null
+ ? Directory.GetCurrentDirectory()
+ : Path.GetFullPath(outputDirectory);
+ var rawPath = Path.Combine(outDir, RawFileName);
+ var fullPath = Path.Combine(outDir, FullFileName);
+ var insightsPath = Path.Combine(outDir, InsightsFileName);
+
+ var rawSnapshot = new RawExportSnapshot
+ {
+ Header = snapshot.Header,
+ Raw = snapshot.Raw,
+ };
+ var insightsSnapshot = new InsightsExportSnapshot
+ {
+ Normalized = snapshot.Normalized,
+ Derived = snapshot.Derived,
+ };
+
+ switch (mode)
+ {
+ case ExportOutputMode.Raw:
+ await JsonExporter.WriteAsync(rawPath, rawSnapshot, jsonOptions).ConfigureAwait(false);
+ Console.Error.WriteLine($"Wrote: {rawPath}");
+ break;
+ case ExportOutputMode.Full:
+ await JsonExporter.WriteAsync(fullPath, snapshot, jsonOptions).ConfigureAwait(false);
+ Console.Error.WriteLine($"Wrote: {fullPath}");
+ break;
+ case ExportOutputMode.Insights:
+ await JsonExporter.WriteAsync(insightsPath, insightsSnapshot, jsonOptions).ConfigureAwait(false);
+ Console.Error.WriteLine($"Wrote: {insightsPath}");
+ break;
+ case ExportOutputMode.Split:
+ await JsonExporter.WriteAsync(rawPath, rawSnapshot, jsonOptions).ConfigureAwait(false);
+ Console.Error.WriteLine($"Wrote: {rawPath}");
+ await JsonExporter.WriteAsync(insightsPath, insightsSnapshot, jsonOptions).ConfigureAwait(false);
+ Console.Error.WriteLine($"Wrote: {insightsPath}");
+ break;
+ }
+
+ return ExitOk;
+ }
+
+ private static void PrintUsage()
+ {
+ Console.Error.WriteLine(
+ "Usage:");
+ Console.Error.WriteLine(
+ " CP2077SaveExporter [--mode raw|full|insights|split] [--facts ] [output-directory]");
+ Console.Error.WriteLine();
+ Console.Error.WriteLine("Default mode: full");
+ Console.Error.WriteLine("Output directory defaults to the current working directory when omitted.");
+ Console.Error.WriteLine();
+ Console.Error.WriteLine("Modes:");
+ Console.Error.WriteLine($" raw — raw decoded data → {RawFileName}");
+ Console.Error.WriteLine($" full — raw + normalized + derived → {FullFileName}");
+ Console.Error.WriteLine($" insights — normalized + derived only → {InsightsFileName}");
+ Console.Error.WriteLine($" split — {RawFileName} + {InsightsFileName} (two files)");
+ Console.Error.WriteLine();
+ Console.Error.WriteLine(
+ "--facts overrides automatic Facts.json discovery; when omitted, Facts.json beside the running executable is used if present.");
+ }
+
+ private static bool TryParseArgs(
+ string[] args,
+ out string savPath,
+ out ExportOutputMode mode,
+ out string? outputDirectory,
+ out string? factsPathOverride,
+ out string? error)
+ {
+ savPath = "";
+ mode = ExportOutputMode.Full;
+ outputDirectory = null;
+ factsPathOverride = null;
+ error = null;
+
+ var positionals = new List();
+
+ for (var i = 0; i < args.Length;)
+ {
+ var a = args[i];
+ if (a == "--mode")
+ {
+ if (i + 1 >= args.Length)
+ {
+ error = "--mode requires a value (raw, full, insights, or split).";
+ return false;
+ }
+
+ var m = args[i + 1].ToLowerInvariant();
+ switch (m)
+ {
+ case "raw":
+ mode = ExportOutputMode.Raw;
+ break;
+ case "full":
+ mode = ExportOutputMode.Full;
+ break;
+ case "insights":
+ mode = ExportOutputMode.Insights;
+ break;
+ case "split":
+ mode = ExportOutputMode.Split;
+ break;
+ default:
+ error = $"Invalid --mode value: {args[i + 1]} (expected raw, full, insights, or split)";
+ return false;
+ }
+
+ i += 2;
+ continue;
+ }
+
+ if (a == "--facts")
+ {
+ if (i + 1 >= args.Length)
+ {
+ error = "--facts requires a path to Facts.json.";
+ return false;
+ }
+
+ factsPathOverride = args[i + 1];
+ i += 2;
+ continue;
+ }
+
+ if (a.Length > 0 && a[0] == '-')
+ {
+ error = $"Unknown option: {a}";
+ return false;
+ }
+
+ positionals.Add(a);
+ i++;
+ }
+
+ if (positionals.Count == 0)
+ {
+ error = "Missing save path.";
+ return false;
+ }
+
+ if (positionals.Count > 2)
+ {
+ error = "Unexpected extra arguments.";
+ return false;
+ }
+
+ savPath = positionals[0];
+ if (string.IsNullOrWhiteSpace(savPath))
+ {
+ error = "Missing save path.";
+ return false;
+ }
+
+ if (savPath.Length > 0 && savPath[0] == '-')
+ {
+ error = $"Unknown option: {savPath}";
+ return false;
+ }
+
+ if (positionals.Count == 2)
+ {
+ var od = positionals[1];
+ if (string.IsNullOrWhiteSpace(od))
+ {
+ error = "Invalid output directory.";
+ return false;
+ }
+
+ if (od.Length > 0 && od[0] == '-')
+ {
+ error = $"Unknown option: {od}";
+ return false;
+ }
+
+ outputDirectory = od;
+ }
+
+ return true;
+ }
+
+ ///
+ /// Facts.json is a single JSON object whose keys are decimal fact hashes (as JSON strings) and values are fact names (strings).
+ /// Uses so deserialization does not depend on Dictionary key typing quirks.
+ ///
+ private static Dictionary? TryParseFactsJsonObject(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ if (doc.RootElement.ValueKind != JsonValueKind.Object)
+ {
+ return null;
+ }
+
+ var dict = new Dictionary();
+ foreach (var prop in doc.RootElement.EnumerateObject())
+ {
+ if (!uint.TryParse(prop.Name, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var key))
+ {
+ continue;
+ }
+
+ if (prop.Value.ValueKind == JsonValueKind.String)
+ {
+ dict[key] = prop.Value.GetString() ?? "";
+ }
+ }
+
+ return dict;
+ }
+}
diff --git a/CP2077SaveExporter/ProgressExtractor.cs b/CP2077SaveExporter/ProgressExtractor.cs
new file mode 100644
index 0000000..587bb1f
--- /dev/null
+++ b/CP2077SaveExporter/ProgressExtractor.cs
@@ -0,0 +1,520 @@
+using System.Globalization;
+using WolvenKit.RED4.Archive.Buffer;
+using WolvenKit.RED4.Save;
+using WolvenKit.RED4.Save.Classes;
+using WolvenKit.RED4.Types;
+using WolvenKit.Common.Services;
+using static WolvenKit.RED4.Types.Enums;
+
+namespace CP2077SaveExporter;
+
+///
+/// Read-only projection from to export DTOs. No mutation of save data.
+///
+public static class ProgressExtractor
+{
+ private const ulong PlayerOwnerHash = 1;
+ private const ulong PlayerInventoryId = 1;
+
+ private static readonly HashSet CyberwareEquipmentAreas = new()
+ {
+ gamedataEquipmentArea.AbilityCW,
+ gamedataEquipmentArea.ArmsCW,
+ gamedataEquipmentArea.CardiovascularSystemCW,
+ gamedataEquipmentArea.CyberwareWheel,
+ gamedataEquipmentArea.EyesCW,
+ gamedataEquipmentArea.FaceCW,
+ gamedataEquipmentArea.FrontalCortexCW,
+ gamedataEquipmentArea.HandsCW,
+ gamedataEquipmentArea.ImmuneSystemCW,
+ gamedataEquipmentArea.IntegumentarySystemCW,
+ gamedataEquipmentArea.LegsCW,
+ gamedataEquipmentArea.MusculoskeletalSystemCW,
+ gamedataEquipmentArea.NervousSystemCW,
+ gamedataEquipmentArea.PersonalLink,
+ gamedataEquipmentArea.SilverhandArm,
+ gamedataEquipmentArea.Splinter,
+ gamedataEquipmentArea.SystemReplacementCW,
+ };
+
+ public static ExportSnapshot Build(
+ CyberpunkSaveFile save,
+ HashService hashService,
+ IReadOnlyDictionary? factNameCatalog = null)
+ {
+ _ = hashService;
+
+ var header = save.FileHeader;
+ var headerDto = new SaveHeaderDto
+ {
+ SaveVersion = header.SaveVersion,
+ GameVersion = header.GameVersion,
+ GameDefPath = header.GameDefPath ?? "",
+ TimeStamp = header.TimeStamp,
+ ArchiveVersion = header.ArchiveVersion,
+ };
+
+ var playerDev = TryGetPlayerDevelopmentData(save);
+ var level = FindProficiencyLevel(playerDev, gamedataProficiencyType.Level);
+ var streetCred = FindProficiencyLevel(playerDev, gamedataProficiencyType.StreetCred);
+
+ var moneyInv = TryGetInventoryMoneyQuantity(save);
+
+ var attrsFlat = ExtractAttributesFlat(playerDev);
+ var invSummary = SummarizeInventory(save);
+ var invRows = CollectInventoryItemRows(save);
+
+ var equipAreas = TryGetPlayerEquipAreas(save);
+ var equippedRaw = CollectEquippedRaw(equipAreas);
+ var cyberRaw = CollectCyberwareRaw(equipAreas);
+
+ var factsAll = CollectQuestFactsRaw(save, factNameCatalog);
+ var ftRaw = CollectFastTravelRaw(save);
+
+ var raw = new RawSectionDto
+ {
+ Level = level,
+ StreetCred = streetCred,
+ Money = new MoneyDto
+ {
+ InventoryItemsMoneyQuantity = moneyInv,
+ StatPoolsSystemCurrency = null,
+ },
+ AttributesFlat = attrsFlat,
+ Inventory = new InventoryRawDto
+ {
+ Summary = invSummary,
+ ItemRows = invRows,
+ },
+ Equipment = new EquipmentRawDto
+ {
+ AllSlots = equippedRaw,
+ CyberwareSlots = cyberRaw,
+ },
+ QuestFactsAll = factsAll,
+ FastTravelPoints = ftRaw,
+ };
+
+ var core = ExportTransforms.SplitCoreAttributes(attrsFlat);
+ var otherStats = ExportTransforms.SplitOtherStats(attrsFlat);
+
+ var equippedIdSet = ExportTransforms.CollectEquippedTweakDbIds(equippedRaw, cyberRaw);
+ var cyberwareIdSet = ExportTransforms.CollectCyberwareTweakDbIds(cyberRaw);
+ var slotsDeduped = ExportTransforms.DedupeEquippedSlots(equippedRaw);
+ var cyberDeduped = ExportTransforms.DedupeCyberwareSlots(cyberRaw);
+ var invNormalizedRows = ExportTransforms.NormalizeInventoryRows(invRows, equippedIdSet, cyberwareIdSet);
+ var slotInstances = ExportTransforms.ToEquippedSlotInstances(equippedRaw);
+ var cyberInstances = ExportTransforms.ToCyberwareSlotInstances(cyberRaw);
+
+ var normalized = new NormalizedSectionDto
+ {
+ CoreAttributes = core,
+ OtherStats = otherStats,
+ Inventory = new InventoryNormalizedDto
+ {
+ Summary = invSummary,
+ ItemRows = invNormalizedRows,
+ },
+ Equipment = new EquipmentNormalizedDto
+ {
+ SlotsDeduped = slotsDeduped,
+ CyberwareDeduped = cyberDeduped,
+ SlotInstances = slotInstances,
+ CyberwareInstances = cyberInstances,
+ },
+ QuestFactsNamedNonZero = ExportTransforms.FilterNamedNonZero(factsAll),
+ };
+
+ var questSignals = ExportTransforms.SummarizeQuestSignals(factsAll);
+ var buildTags = ExportTransforms.BuildTags(level, streetCred, core, cyberDeduped.Count);
+ var inventoryInsights = ExportTransforms.SummarizeInventoryInsights(invNormalizedRows, level, moneyInv);
+ var derived = new DerivedSectionDto
+ {
+ BuildTags = buildTags,
+ ProgressionStage = ExportTransforms.ProgressionStageFromLevel(level),
+ QuestFactSummary = ExportTransforms.SummarizeFacts(factsAll),
+ QuestSignals = questSignals,
+ InventorySummary = ExportTransforms.SummarizeInventoryCategories(invNormalizedRows),
+ EquipmentSummary = ExportTransforms.SummarizeEquipmentLoadout(slotsDeduped, cyberInstances),
+ Completion = ExportTransforms.SummarizeCompletionMetrics(factsAll),
+ Expansion = ExportTransforms.SummarizeExpansion(factsAll, ftRaw, questSignals),
+ InventoryInsights = inventoryInsights,
+ EquipmentMaturity = ExportTransforms.SummarizeEquipmentMaturity(
+ level,
+ moneyInv,
+ invNormalizedRows,
+ slotsDeduped,
+ cyberDeduped,
+ cyberInstances.Count),
+ BuildProfile = ExportTransforms.SummarizeBuildProfile(core, buildTags, cyberInstances, inventoryInsights),
+ Exploration = ExportTransforms.SummarizeExploration(ftRaw),
+ };
+
+ return new ExportSnapshot
+ {
+ Header = headerDto,
+ Raw = raw,
+ Normalized = normalized,
+ Derived = derived,
+ };
+ }
+
+ private static RedPackage? TryGetScriptablePackage(CyberpunkSaveFile save)
+ {
+ var node = save.Nodes.FirstOrDefault(n => n.Name == Constants.NodeNames.SCRIPTABLE_SYSTEMS_CONTAINER);
+ if (node?.Value is not Package { Content: RedPackage pkg })
+ {
+ return null;
+ }
+
+ return pkg;
+ }
+
+ private static PlayerDevelopmentData? TryGetPlayerDevelopmentData(CyberpunkSaveFile save)
+ {
+ var pkg = TryGetScriptablePackage(save);
+ if (pkg == null)
+ {
+ return null;
+ }
+
+ var sys = pkg.Chunks.OfType().FirstOrDefault();
+ if (sys == null)
+ {
+ return null;
+ }
+
+ foreach (var h in sys.PlayerData)
+ {
+ if (h.Chunk != null && (ulong)h.Chunk.OwnerID.Hash == PlayerOwnerHash)
+ {
+ return h.Chunk;
+ }
+ }
+
+ return null;
+ }
+
+ private static int? FindProficiencyLevel(PlayerDevelopmentData? pd, gamedataProficiencyType kind)
+ {
+ if (pd == null)
+ {
+ return null;
+ }
+
+ foreach (var p in pd.Proficiencies)
+ {
+ if ((gamedataProficiencyType)p.Type == kind)
+ {
+ return (int)p.CurrentLevel;
+ }
+ }
+
+ return null;
+ }
+
+ private static ulong? TryGetInventoryMoneyQuantity(CyberpunkSaveFile save)
+ {
+ var node = save.Nodes.FirstOrDefault(n => n.Name == Constants.NodeNames.INVENTORY);
+ if (node?.Value is not Inventory inv)
+ {
+ return null;
+ }
+
+ foreach (var sub in inv.SubInventories)
+ {
+ if (sub.InventoryId != PlayerInventoryId)
+ {
+ continue;
+ }
+
+ foreach (var item in sub.Items)
+ {
+ var idText = item.ItemInfo.ItemId.Id.GetResolvedText();
+ if (idText == "Items.money")
+ {
+ return item.Quantity;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static IReadOnlyList ExtractAttributesFlat(PlayerDevelopmentData? pd)
+ {
+ if (pd == null)
+ {
+ return Array.Empty();
+ }
+
+ var list = new List();
+ foreach (var a in pd.Attributes)
+ {
+ var name = ((gamedataStatType)a.AttributeName).ToString();
+ list.Add(new AttributeFlatDto { StatType = name, Value = (int)a.Value });
+ }
+
+ return list;
+ }
+
+ private static InventorySummaryDto SummarizeInventory(CyberpunkSaveFile save)
+ {
+ var node = save.Nodes.FirstOrDefault(n => n.Name == Constants.NodeNames.INVENTORY);
+ if (node?.Value is not Inventory inv)
+ {
+ return new InventorySummaryDto();
+ }
+
+ var subs = new List();
+ var total = 0;
+ foreach (var sub in inv.SubInventories)
+ {
+ var c = sub.Items.Count;
+ total += c;
+ subs.Add(new SubInventorySummaryDto
+ {
+ InventoryId = sub.InventoryId.ToString(CultureInfo.InvariantCulture),
+ ItemCount = c,
+ });
+ }
+
+ return new InventorySummaryDto
+ {
+ SubInventoryCount = inv.SubInventories.Count,
+ TotalItemStacks = total,
+ SubInventories = subs,
+ };
+ }
+
+ private static IReadOnlyList CollectInventoryItemRows(CyberpunkSaveFile save)
+ {
+ var node = save.Nodes.FirstOrDefault(n => n.Name == Constants.NodeNames.INVENTORY);
+ if (node?.Value is not Inventory inv)
+ {
+ return Array.Empty();
+ }
+
+ var rows = new List();
+ foreach (var sub in inv.SubInventories)
+ {
+ var invId = sub.InventoryId.ToString(CultureInfo.InvariantCulture);
+ foreach (var item in sub.Items)
+ {
+ var tid = item.ItemInfo.ItemId.Id;
+ rows.Add(new InventoryItemRawDto
+ {
+ InventoryId = invId,
+ TweakDbIdUlong = ((ulong)tid).ToString(CultureInfo.InvariantCulture),
+ ItemIdResolved = tid.GetResolvedText(),
+ ItemIdPresentation = tid.ToString(),
+ Quantity = item.Quantity,
+ Flags = item.Flags.ToString(),
+ CreationTime = item.CreationTime,
+ ItemStructure = item.ItemInfo.ItemStructure.ToString(),
+ });
+ }
+ }
+
+ return rows;
+ }
+
+ private static CArray? TryGetPlayerEquipAreas(CyberpunkSaveFile save)
+ {
+ var pkg = TryGetScriptablePackage(save);
+ if (pkg == null)
+ {
+ return null;
+ }
+
+ var equip = pkg.Chunks.OfType().FirstOrDefault();
+ if (equip == null)
+ {
+ return null;
+ }
+
+ foreach (var h in equip.OwnerData)
+ {
+ if (h.Chunk != null && (ulong)h.Chunk.OwnerID.Hash == PlayerOwnerHash)
+ {
+ return h.Chunk.Equipment.EquipAreas;
+ }
+ }
+
+ return null;
+ }
+
+ private static IReadOnlyList CollectEquippedRaw(CArray? areas)
+ {
+ if (areas == null)
+ {
+ return Array.Empty();
+ }
+
+ var list = new List();
+ var weaponSlot = 1;
+ foreach (var area in areas)
+ {
+ var gt = (gamedataEquipmentArea)area.AreaType;
+ var areaLabel = gt.ToString();
+ if (gt == gamedataEquipmentArea.Weapon)
+ {
+ areaLabel = "Weapon " + weaponSlot;
+ weaponSlot++;
+ }
+
+ if (area.EquipSlots == null)
+ {
+ continue;
+ }
+
+ foreach (var slot in area.EquipSlots)
+ {
+ if (slot.ItemID == null || (ulong)slot.ItemID.Id == 0)
+ {
+ continue;
+ }
+
+ var id = slot.ItemID.Id;
+ list.Add(new EquippedSlotRawDto
+ {
+ AreaTypeLabel = areaLabel,
+ AreaTypeEnum = gt.ToString(),
+ TweakDbIdUlong = ((ulong)id).ToString(CultureInfo.InvariantCulture),
+ ItemIdResolved = id.GetResolvedText(),
+ ItemIdPresentation = id.ToString(),
+ });
+ }
+ }
+
+ return list;
+ }
+
+ private static IReadOnlyList CollectCyberwareRaw(CArray? areas)
+ {
+ if (areas == null)
+ {
+ return Array.Empty();
+ }
+
+ var list = new List();
+ foreach (var area in areas)
+ {
+ var gt = (gamedataEquipmentArea)area.AreaType;
+ if (!CyberwareEquipmentAreas.Contains(gt))
+ {
+ continue;
+ }
+
+ if (area.EquipSlots == null)
+ {
+ continue;
+ }
+
+ foreach (var slot in area.EquipSlots)
+ {
+ if (slot.ItemID == null || (ulong)slot.ItemID.Id == 0)
+ {
+ continue;
+ }
+
+ var id = slot.ItemID.Id;
+ list.Add(new CyberwareSlotRawDto
+ {
+ AreaTypeEnum = gt.ToString(),
+ TweakDbIdUlong = ((ulong)id).ToString(CultureInfo.InvariantCulture),
+ ItemIdResolved = id.GetResolvedText(),
+ ItemIdPresentation = id.ToString(),
+ });
+ }
+ }
+
+ return list;
+ }
+
+ private static IReadOnlyList CollectQuestFactsRaw(
+ CyberpunkSaveFile save,
+ IReadOnlyDictionary? factNameCatalog)
+ {
+ var list = new List();
+ var qs = save.Nodes.FirstOrDefault(n => n.Name == Constants.NodeNames.QUEST_SYSTEM);
+ if (qs == null)
+ {
+ return list;
+ }
+
+ foreach (var child in qs.Children)
+ {
+ if (child.Name != Constants.NodeNames.FACTSDB)
+ {
+ continue;
+ }
+
+ if (child.Value is not FactsDB db)
+ {
+ continue;
+ }
+
+ foreach (var table in db.FactsTables)
+ {
+ foreach (var fe in table.FactEntries)
+ {
+ var hash = (uint)fe.FactName;
+ string? name = null;
+ if (factNameCatalog != null && factNameCatalog.TryGetValue(hash, out var n))
+ {
+ name = n;
+ }
+
+ list.Add(new QuestFactRawDto
+ {
+ FactHash = hash,
+ FactHashHex = "0x" + hash.ToString("X8", CultureInfo.InvariantCulture),
+ Name = name,
+ Value = fe.Value,
+ });
+ }
+ }
+ }
+
+ return list;
+ }
+
+ private static IReadOnlyList CollectFastTravelRaw(CyberpunkSaveFile save)
+ {
+ var pkg = TryGetScriptablePackage(save);
+ if (pkg == null)
+ {
+ return Array.Empty();
+ }
+
+ var fts = pkg.Chunks.OfType().FirstOrDefault();
+ if (fts == null)
+ {
+ return Array.Empty();
+ }
+
+ var list = new List();
+ foreach (var h in fts.FastTravelNodes)
+ {
+ if (h.Chunk == null)
+ {
+ continue;
+ }
+
+ var d = h.Chunk;
+ var pr = d.PointRecord;
+ var mr = d.MarkerRef;
+ list.Add(new FastTravelPointRawDto
+ {
+ PointRecordResolved = pr.GetResolvedText(),
+ PointRecordTweakDbUlong = ((ulong)pr).ToString(CultureInfo.InvariantCulture),
+ MarkerRefResolved = mr.GetResolvedText(),
+ MarkerRefUlong = ((ulong)mr).ToString(CultureInfo.InvariantCulture),
+ IsEp1 = (bool)d.IsEP1,
+ });
+ }
+
+ return list;
+ }
+}
diff --git a/CP2077SaveExporter/README.md b/CP2077SaveExporter/README.md
new file mode 100644
index 0000000..8d35bae
--- /dev/null
+++ b/CP2077SaveExporter/README.md
@@ -0,0 +1,279 @@
+# CP2077 Save Exporter
+
+
+
+
+
+---
+
+## 🚀 Overview
+
+**CP2077 Save Exporter** is a **read-only CLI tool** that converts Cyberpunk 2077 save files into structured JSON optimized for **AI-driven gameplay analysis**.
+
+It uses the in-repo WolvenKit stack (via CyberCAT) to decode save data safely and deterministically.
+
+It is designed to answer:
+
+- What should I do next to reach 100% completion?
+- Where am I under-progressed?
+- Is my build efficient?
+- What should I do in a 30 / 60 / 120 minute session?
+
+---
+
+## 🧠 Design Intent
+
+This is **not a save editor** and does not depend on the CyberCAT GUI.
+
+It is a **game-state intelligence layer** built for:
+
+- structured interpretation
+- AI reasoning
+- repeatable decision-making
+
+| Principle | Description |
+|----------|------------|
+| Read-only | Never modifies save files |
+| Signal-first | Focus on actionable insights |
+| AI-native | Designed for LLM consumption |
+| Deterministic | Same input → same output |
+
+---
+
+## 📦 Output Modes
+
+```bash
+CP2077SaveExporter [--mode raw|full|insights|split] [--facts ] [output-directory]
+```
+
+| Mode | Output | Use Case |
+|------|--------|---------|
+| raw | save.raw.json | Debug / inspection |
+| full (default) | save.full.json | Canonical dataset (recommended for AI) |
+| insights | save.insights.json | Derived-only (advanced use) |
+| split | raw + insights | Separation workflows |
+
+---
+
+## 🧱 Data Model
+
+### Full Output (Canonical)
+
+```json
+{
+ "header": {},
+ "raw": {},
+ "normalized": {},
+ "derived": {}
+}
+```
+
+---
+
+## 📊 Derived Signals (Core Value)
+
+Derived signals summarize patterns in the data so AI models can reason efficiently without scanning the entire raw dataset:
+
+- Progression stage
+- Completion signals
+- Build profile
+- Cyberware utilization
+- Inventory insights
+- Exploration proxy
+- Expansion detection
+
+> These are **heuristics**, not exact values.
+
+---
+
+## 🖼️ Example Workflow
+
+```bash
+CP2077SaveExporter --mode full sav.dat
+```
+
+Then provide the JSON to an AI model with a structured prompt, for example:
+
+> “Given this save state, what should I do next to efficiently reach 100% completion?”
+
+---
+
+## 🤖 AI Usage Guidance
+
+For best results:
+
+- Use **FULL mode**
+- Combine:
+ - derived signals (direction)
+ - raw data (grounding)
+
+---
+
+## ⚙️ Installation & Build
+
+### Requirements
+- .NET 8 SDK
+
+### Development Run
+
+Run from the repository root (required for WolvenKit project references to resolve correctly):
+
+```bash
+dotnet run --project CP2077SaveExporter --
+```
+
+### Publish (Recommended)
+
+Use the provided scripts to build standalone executables.
+
+These builds are isolated to this component and do not affect the main solution or GUI project.
+
+From inside `CP2077SaveExporter/`:
+
+#### Linux / macOS
+```bash
+./publish.sh
+```
+
+#### Windows (PowerShell)
+```powershell
+.\publish.ps1
+```
+
+Outputs:
+
+```
+CP2077SaveExporter/publish/
+ ├── win-x64/
+ └── linux-x64/
+```
+
+Each directory contains a self-contained, single-file executable and required runtime components.
+
+---
+
+## 🧾 Facts.json Handling
+
+`Facts.json` maps internal game fact hashes to human-readable names.
+
+### Canonical Source
+
+The authoritative upstream file is located in the CyberCAT repository:
+
+```
+https://github.com/Deweh/CyberCAT-SimpleGUI
+./CP2077SaveEditor/Resources/Facts.json
+```
+
+This should be treated as the **baseline canonical mapping**.
+
+---
+
+### Runtime Resolution
+
+The exporter resolves `Facts.json` in the following order:
+
+1. `--facts `
+ → Explicit override (recommended when testing or extending)
+
+2. `Facts.json` beside the executable
+ → Drop-in file next to the published binary
+
+3. Fallback
+ → If not found, exporter continues with hash-only values (with warning)
+
+---
+
+### Recommended Usage
+
+```bash
+CP2077SaveExporter --facts /path/to/Facts.json sav.dat
+```
+
+or place:
+
+```
+Facts.json
+```
+
+in the same directory as the executable.
+
+---
+
+### Future Enhancement
+
+`Facts.json` is intentionally external and extensible.
+
+You can:
+
+- augment it with additional hash mappings
+- maintain a personal extended version
+- contribute improvements upstream
+
+This allows progressive improvement of semantic decoding without modifying exporter code.
+
+---
+
+## ⚠️ Limitations
+
+- Derived signals are heuristic
+- Progress is approximate
+- Fact coverage is incomplete
+
+> The goal is **useful decisions**, not perfect simulation.
+
+---
+
+## 🛠️ Development Philosophy
+
+We optimize for:
+
+- interpretability
+- signal clarity
+- cross-model stability
+
+We avoid:
+
+- unnecessary complexity
+- deep reverse engineering
+
+---
+
+## 📌 Roadmap
+
+- Improve build signals
+- Reduce ambiguity
+- Expand fact coverage
+- Improve cross-AI consistency
+
+---
+
+## Optional AI Usage Resources
+
+Example downstream AI-analysis materials are available separately:
+
+- Example analysis prompt: [[link](https://gist.github.com/generationtech/8f1def7976a2ab011118624672740107)]
+- Prompt evaluation notes: [[link](https://gist.github.com/generationtech/41f97dd27ccb3b9bacb5ea6d7617a1a3)]
+
+These are optional reference materials and are not required to use the exporter.
+
+---
+
+## 🤝 Contributing
+
+Focus on:
+
+- signal quality
+- clarity
+- consistency
+
+Avoid:
+
+- large refactors
+- architectural changes
+
+---
+
+## 📄 License
+
+MIT
diff --git a/CP2077SaveExporter/SaveLoader.cs b/CP2077SaveExporter/SaveLoader.cs
new file mode 100644
index 0000000..96afb38
--- /dev/null
+++ b/CP2077SaveExporter/SaveLoader.cs
@@ -0,0 +1,21 @@
+using WolvenKit.RED4.Save;
+using WolvenKit.RED4.Save.IO;
+
+namespace CP2077SaveExporter;
+
+///
+/// Opens sav.dat read-only and returns a parsed .
+///
+public static class SaveLoader
+{
+ ///
+ /// Loads the CSAV. The returned is independent of the reader after this call returns.
+ ///
+ public static (EFileReadErrorCodes Code, CyberpunkSaveFile? File) Load(string path)
+ {
+ using var fs = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read);
+ using var reader = new CyberpunkSaveReader(fs);
+ var code = reader.ReadFile(out var file);
+ return (code, file);
+ }
+}
diff --git a/CP2077SaveExporter/publish.ps1 b/CP2077SaveExporter/publish.ps1
new file mode 100644
index 0000000..d5bbf40
--- /dev/null
+++ b/CP2077SaveExporter/publish.ps1
@@ -0,0 +1,7 @@
+#Requires -Version 5.1
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+Set-Location $PSScriptRoot
+
+dotnet publish CP2077SaveExporter.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish/win-x64
+dotnet publish CP2077SaveExporter.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -o publish/linux-x64
diff --git a/CP2077SaveExporter/publish.sh b/CP2077SaveExporter/publish.sh
new file mode 100755
index 0000000..e59c3d4
--- /dev/null
+++ b/CP2077SaveExporter/publish.sh
@@ -0,0 +1,6 @@
+#!/usr/bin/env bash
+set -euo pipefail
+cd "$(dirname "$0")"
+
+dotnet publish CP2077SaveExporter.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish/win-x64
+dotnet publish CP2077SaveExporter.csproj -c Release -r linux-x64 --self-contained true -p:PublishSingleFile=true -o publish/linux-x64