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 + +![.NET](https://img.shields.io/badge/.NET-8.0-blue) +![Status](https://img.shields.io/badge/status-active-success) +![License](https://img.shields.io/badge/license-MIT-green) + +--- + +## 🚀 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