diff --git a/SVSim.Database/Models/Config/ResourceConfig.cs b/SVSim.Database/Models/Config/ResourceConfig.cs
new file mode 100644
index 0000000..6a5373e
--- /dev/null
+++ b/SVSim.Database/Models/Config/ResourceConfig.cs
@@ -0,0 +1,37 @@
+namespace SVSim.Database.Models.Config;
+
+///
+/// Asset-delivery tunables: where the client looks for the resource CDN (Akamai by default;
+/// Wizard/SetUp.cs:48 hardcodes shadowverse.akamaized.net/) and what manifest
+/// version to ask for. Currently a single field, will grow as we self-host content.
+///
+[ConfigSection("ResourceConfig")]
+public class ResourceConfig
+{
+ ///
+ /// Pushed to the client as data_headers.required_res_ver. The client writes it to
+ /// PlayerPrefs["RES_VER"] and uses it as the version path component for asset
+ /// manifest lookups: https://<cdn>/dl/Manifest/<RES_VER>/<lang>/<Platform>/.
+ ///
+ /// Default value is the prod-captured version from data_dumps/traffic_prod_tutorial.ndjson
+ /// (2026-05-28) — i.e., a path Akamai actually serves. When this rotates (or Akamai sunsets
+ /// ahead of June 2026), update via DB GameConfigs row, appsettings.json, or this
+ /// shipped default; no code change needed.
+ ///
+ ///
+ /// When the client has no cached RES_VER (e.g., a wiped/fresh install via
+ /// NukeIdentityOnStartup), it defaults to "00000000", which Akamai 404s. The
+ /// fetch failure surfaces as "Connection Error / Reconnect" before any tutorial UI loads,
+ /// so emitting a valid value here is required for fresh-account boot.
+ ///
+ ///
+ public string RequiredResVer { get; set; } = "4670rPsPMVlRTd2";
+
+ ///
+ /// Inline-default tier for . Mirrors property initialisers
+ /// — kept as a separate factory because the framework requires every [ConfigSection] POCO to
+ /// expose one (see feedback_config_defaults memory for the collection-defaults rule
+ /// that motivated the convention).
+ ///
+ public static ResourceConfig ShippedDefaults() => new();
+}
diff --git a/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs b/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs
index cd32b34..8bcc3da 100644
--- a/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs
+++ b/SVSim.EmulatedEntrypoint/Middlewares/ShadowverseTranslationMiddleware.cs
@@ -9,6 +9,8 @@ using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json.Linq;
using SVSim.Database.Models;
+using SVSim.Database.Models.Config;
+using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Infrastructure;
@@ -26,6 +28,7 @@ public class ShadowverseTranslationMiddleware : IMiddleware
{
private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider;
private readonly ShadowverseSessionService _sessionService;
+ private readonly IGameConfigService _gameConfig;
private readonly ILogger _logger;
// Serialization policy MUST match what AddJsonOptions configured on the controllers, or the
@@ -41,10 +44,12 @@ public class ShadowverseTranslationMiddleware : IMiddleware
public ShadowverseTranslationMiddleware(
IActionDescriptorCollectionProvider actionDescriptorCollectionProvider,
ShadowverseSessionService sessionService,
+ IGameConfigService gameConfig,
ILogger logger)
{
_actionDescriptorCollectionProvider = actionDescriptorCollectionProvider;
_sessionService = sessionService;
+ _gameConfig = gameConfig;
_logger = logger;
}
@@ -173,42 +178,73 @@ public class ShadowverseTranslationMiddleware : IMiddleware
? null
: ConvertJsonTreeToPlainObject(JToken.Parse(responseJson));
+ // Build the headers as a strongly-typed POCO so this construction site stays type-safe
+ // (the alternative — a Dictionary with literal-string keys here — is the
+ // anti-pattern documented in the feedback_no_lazy_response_dicts memory).
+ DataHeaders typedHeaders = new DataHeaders
+ {
+ Servertime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
+ // SID intentionally empty. See docs/api-spec/common/envelope.md §"SID
+ // rotation" — the client's SessionId is a hash-on-read property, so echoing
+ // the request's SID poisons its backing field and the next request hashes
+ // the hash, missing our SID→UDID dict and crashing decryption. To rotate
+ // sessions in the future, use the "stable-prefix + counter" pattern from
+ // that doc (Option B), and pre-hash the rotated value to index the map by
+ // what the client will actually send back on the next request.
+ Sid = "",
+ // Pushed ONLY on /check/game_start. NetworkTask.Parse opens the
+ // "new data is available" popup whenever required_res_ver is present in
+ // data_headers AND the URL isn't GameStartCheck (NetworkTask.cs:128-138 — the
+ // popup is unconditionally skipped on game_start). Emitting on game_start
+ // silently bumps PlayerPrefs["RES_VER"] before ResourceDownloader runs;
+ // emitting anywhere else would surface a spurious "new data" dialog on every
+ // boot for any client whose cached RES_VER trails the server's current value.
+ RequiredResVer = path.Equals("/check/game_start", StringComparison.OrdinalIgnoreCase)
+ ? _gameConfig.Get().RequiredResVer
+ : null,
+ // TODO error handling
+ ResultCode = 1,
+ // Anonymous endpoints (e.g. /check/special_title with [AllowAnonymous]) reach this
+ // middleware without an authenticated viewer — the auth handler either declined or
+ // failed to find a Steam-linked viewer. The wire still needs short_udid / viewer_id
+ // populated (prod sends real numbers for the title check too, but 0 / 0 satisfies
+ // the client's BaseTask.Parse which only reads result_code + servertime here).
+ ShortUdid = skipEncryption ? 0 : (viewer?.ShortUdid ?? 0),
+ ViewerId = skipEncryption ? 0 : (viewer?.Id ?? 0),
+ // Echo the decrypted-against UDID. Most clients ignore this field; SignUpTask.Parse
+ // requires it (validates against Certification.Udid on the response). Comes from
+ // mappedUdid (the value used for AES); never from controller state.
+ Udid = skipEncryption ? "" : (mappedUdid?.ToString() ?? "")
+ };
+
+ // Route the typed headers through the same STJ→JToken→dict pipeline that the controller
+ // response (Data) goes through. STJ honours the global WhenWritingNull policy, so null
+ // optional fields are absent from the JSON; ConvertJsonTreeToPlainObject preserves
+ // "absent vs null" all the way to msgpack. Without this, MessagePack's contractless
+ // resolver would walk the typed properties and emit "key":null for every nullable
+ // field — RequiredResVer being the load-bearing case (a spurious null fires the
+ // "new data available" popup via NetworkTask.isResourceVersionUp on every non-
+ // game_start endpoint).
+ string headersJson = JsonSerializer.Serialize(typedHeaders, ControllerJsonOptions);
+ Dictionary headersDict =
+ (ConvertJsonTreeToPlainObject(JToken.Parse(headersJson)) as Dictionary)
+ ?? throw new InvalidOperationException(
+ "DataHeaders JSON projection didn't yield a JSON object — this should be unreachable: " +
+ "DataHeaders is a typed POCO that always serializes to a single JSON object root.");
+
// Wrap the response in a datawrapper. Portal (no-encryption) endpoints emit an anonymous
// envelope — viewer/udid/sid stay zero/empty — matching the prod portal traffic shape
// captured in data_dumps/traffic_prod_deckcode.ndjson.
DataWrapper wrappedResponseData = new DataWrapper
{
Data = responseData,
- DataHeaders = new DataHeaders
- {
- Servertime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
- // SID intentionally empty. See docs/api-spec/common/envelope.md §"SID
- // rotation" — the client's SessionId is a hash-on-read property, so echoing
- // the request's SID poisons its backing field and the next request hashes
- // the hash, missing our SID→UDID dict and crashing decryption. To rotate
- // sessions in the future, use the "stable-prefix + counter" pattern from
- // that doc (Option B), and pre-hash the rotated value to index the map by
- // what the client will actually send back on the next request.
- Sid = "",
- // TODO error handling
- ResultCode = 1,
- // Anonymous endpoints (e.g. /check/special_title with [AllowAnonymous]) reach this
- // middleware without an authenticated viewer — the auth handler either declined or
- // failed to find a Steam-linked viewer. The wire still needs short_udid / viewer_id
- // populated (prod sends real numbers for the title check too, but 0 / 0 satisfies
- // the client's BaseTask.Parse which only reads result_code + servertime here).
- ShortUdid = skipEncryption ? 0 : (viewer?.ShortUdid ?? 0),
- ViewerId = skipEncryption ? 0 : (viewer?.Id ?? 0),
- // Echo the decrypted-against UDID. Most clients ignore this field; SignUpTask.Parse
- // requires it (validates against Certification.Udid on the response). Comes from
- // mappedUdid (the value used for AES); never from controller state.
- Udid = skipEncryption ? "" : (mappedUdid?.ToString() ?? "")
- }
+ DataHeaders = headersDict
};
// Convert the response into a messagepack, encrypt it. ContractlessStandardResolver
- // walks the DataWrapper's typed properties (DataHeaders) AND the boxed object/list/
- // primitive tree under Data — emitting only the keys present in the dictionary.
+ // walks the boxed object/list/primitive tree under both DataHeaders and Data —
+ // emitting only the keys present in each dictionary. Null-valued optional fields are
+ // already stripped upstream by the STJ + ConvertJsonTreeToPlainObject pipeline.
var msgPackOptions = MessagePackSerializerOptions.Standard
.WithResolver(ContractlessStandardResolver.Instance);
// Both branches base64-wrap the response body — the client's NetworkManager.Connect
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataHeaders.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataHeaders.cs
index e39bd61..208d32a 100644
--- a/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataHeaders.cs
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataHeaders.cs
@@ -32,4 +32,23 @@ public class DataHeaders
[JsonPropertyName("udid")]
[Key("udid")]
public string Udid { get; set; } = "";
+
+ ///
+ /// Tells the client the required version path component for asset manifests on the
+ /// resource server (Akamai CDN, hardcoded to shadowverse.akamaized.net/ in
+ /// Wizard/SetUp.cs:48). NetworkTask.setResourceVersion writes the value
+ /// to PlayerPrefs["RES_VER"]; the manifest URL becomes
+ /// dl/Manifest/<RES_VER>/<lang>/<Platform>/. When the client
+ /// has no cached RES_VER (e.g., after NukeIdentityOnStartup wipes
+ /// PlayerPrefs), it defaults to "00000000", which Akamai doesn't serve — the
+ /// manifest fetch 404s and the client shows "Connection Error / Reconnect" before
+ /// the tutorial UI ever appears.
+ ///
+ /// Nullable to keep it off the wire on responses that don't need it (the global
+ /// WhenWritingNull policy in Program.cs handles the omission).
+ ///
+ ///
+ [JsonPropertyName("required_res_ver")]
+ [Key("required_res_ver")]
+ public string? RequiredResVer { get; set; }
}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataWrapper.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataWrapper.cs
index 150668c..a2d0c40 100644
--- a/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataWrapper.cs
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Internal/DataWrapper.cs
@@ -10,11 +10,20 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.Internal;
public class DataWrapper
{
///
- /// Additional data about the request, response and user.
+ /// Wire-shape projection of the response envelope headers. The middleware builds a
+ /// strongly-typed POCO and runs it through the same STJ +
+ /// ConvertJsonTreeToPlainObject pipeline that the controller's response goes
+ /// through, yielding this dict with absent keys for null-valued optional fields.
+ /// Typed as (not ) because
+ /// the projected shape is fully known — only the per-key value type varies. Direct
+ /// assignment of the typed POCO would let MessagePack's contractless resolver emit
+ /// "key":null for nullables, which the client treats as "key present" via
+ /// Keys.Contains (see NetworkTask.isResourceVersionUp for the
+ /// load-bearing case).
///
[JsonPropertyName("data_headers")]
- [Key("data_headers")]
- public DataHeaders DataHeaders { get; set; } = new DataHeaders();
+ [Key("data_headers")]
+ public Dictionary DataHeaders { get; set; } = new();
///
/// The response data from the endpoint.
diff --git a/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs b/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs
index c33040f..ea39563 100644
--- a/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs
+++ b/SVSim.UnitTests/Models/GameConfigurationJsonbTests.cs
@@ -25,14 +25,19 @@ public class GameConfigurationJsonbTests
var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
var byName = rows.ToDictionary(r => r.SectionName);
- // One row per [ConfigSection]-marked POCO (8 sections today: Player, DefaultGrants,
- // DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story).
+ // One row per [ConfigSection]-marked POCO (9 sections today: Player, DefaultGrants,
+ // DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig).
Assert.That(byName.Keys, Is.EquivalentTo(new[]
{
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
- "MyRotationSchedule", "Story",
+ "MyRotationSchedule", "Story", "ResourceConfig",
}));
+ var resources = JsonSerializer.Deserialize(byName["ResourceConfig"].ValueJson)!;
+ Assert.That(resources.RequiredResVer, Is.EqualTo("4670rPsPMVlRTd2"),
+ "ShippedDefaults RES_VER is the prod-captured (2026-05-28) Akamai manifest path " +
+ "— required by the client to load the asset manifest after a wiped/fresh install.");
+
var mrSchedule = JsonSerializer.Deserialize(byName["MyRotationSchedule"].ValueJson)!;
Assert.That(mrSchedule.FreeBattle.Begin, Is.EqualTo(new DateTime(2024, 5, 1, 20, 0, 0, DateTimeKind.Utc)),
"ShippedDefaults reproduces the 2026-05-23 prod capture so a fresh install ships with Custom Rotation enabled");