Skip to content

Commit 989a210

Browse files
Merge pull request #33 from CoderGamester/develop
Release 2.1.0
2 parents 971e854 + a560db6 commit 989a210

9 files changed

Lines changed: 135 additions & 50 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ flowchart TD
128128

129129
### Build/Version Info
130130
`Runtime/VersionServices.cs`
131-
- Static class for `version-data` Resources metadata. `VersionExternal` is always safe; `VersionInternal`, `Branch`, `Commit`, and `BuildNumber` require either `LoadVersionData()` (sync) or `LoadVersionDataAsync()` (async) to have completed successfully first.
131+
- Static class for `version-data` Resources metadata. `VersionExternal` is always safe. `VersionInternal`, `Branch`, `Commit`, and `BuildNumber` auto-load via a `[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` `Bootstrap` hook and additionally lazy-load on first property access (via the private `EnsureLoaded()`) when the bootstrap hook has not yet fired. Consumers do NOT need to call `LoadVersionData()` / `LoadVersionDataAsync()` explicitly for the default flow. Both load methods remain public for explicit pre-warming.
132132

133133
## 3. Key Directories / Files
134134

@@ -243,8 +243,8 @@ The concrete `PoolService` stays in `Runtime/` root under `GameLovers.Services`
243243
- `VersionEditorUtils` writes `version-data.txt` on every domain reload (`[InitializeOnLoadMethod]`) and can be invoked by build pipelines. It uses git CLI; failures are handled gracefully.
244244
- The **write folder** is configurable per-project via `VersioningEditorSettings.instance.ResourcesFolderPath` (default `Assets/Configs/Resources`). Change it from the Versioning tab in the Services Explorer (browse + reset). The chosen folder must contain a `Resources` path segment so `Resources.Load<TextAsset>("version-data")` can locate the file at runtime.
245245
- `VersioningEditorSettings` persists to `ProjectSettings/VersioningEditorSettings.asset` (editor-only, not committed to version control by default).
246-
- `VersionExternal` is always safe (reads `Application.version` directly). `VersionInternal`, `Branch`, `Commit`, and `BuildNumber` throw `Exception("Version Data not loaded.")` if data has not been loaded — call `LoadVersionData()` (sync) or `LoadVersionDataAsync()` (async) early in boot.
247-
- **Sync vs async load**: `LoadVersionData()` uses `Resources.Load<TextAsset>` (synchronous, main-thread); `LoadVersionDataAsync()` uses `Resources.LoadAsync<TextAsset>` wrapped in a `TaskCompletionSource`. Both funnel into the private `ApplyTextAsset(TextAsset, bool asyncContext)` helper that parses JSON, flips `_loaded`, and calls `Resources.UnloadAsset`. Behaviour is identical apart from the wording in the failure log line (`"Could not async load …"` vs `"Could not load …"`). Sync is the recommended default for the shipping `version-data.txt` (a few hundred bytes); async is only worth the ceremony if `VersionData` is extended with large embedded blobs (e.g. a baked manifest) that would noticeably stall the main thread.
246+
- `VersionExternal` is always safe (reads `Application.version` directly). `VersionInternal`, `Branch`, `Commit`, and `BuildNumber` auto-bootstrap at `[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]` (private `Bootstrap` method calling `LoadVersionData()`), and additionally lazy-load on first property access via the private `EnsureLoaded()` — covering the case where a sibling-assembly `SubsystemRegistration` callback fires before this package's. When the `version-data` Resource is missing or fails to parse the accessors return their documented fallbacks (`VersionInternal``Application.version`, others → `string.Empty`) and a `Debug.LogError` is emitted; no exception is raised.
247+
- **Sync vs async load**: `LoadVersionData()` uses `Resources.Load<TextAsset>` (synchronous, main-thread); `LoadVersionDataAsync()` uses `Resources.LoadAsync<TextAsset>` wrapped in a `TaskCompletionSource`. Both funnel into the private `ApplyTextAsset(TextAsset, bool asyncContext)` helper that parses JSON, flips `_loaded`, and calls `Resources.UnloadAsset`. Behaviour is identical apart from the wording in the failure log line (`"Could not async load …"` vs `"Could not load …"`). The sync variant is what `Bootstrap` calls and what `EnsureLoaded()` falls back to; both methods remain public for callers that want explicit pre-warming. The async variant is only worth the ceremony if `VersionData` is extended with large embedded blobs (e.g. a baked manifest) that would noticeably stall the main thread.
248248
- `VersionServices.IsOutdatedVersion(string)` requires a 3-part `Major.Minor.Patch` semver and throws `IndexOutOfRangeException` on any 1- or 2-part input — the parser unconditionally accesses `Split('.')[0..2]`. Consumers that compare against `Application.version` must ensure `ProjectSettings.bundleVersion` is 3-part (defaults to `0.1` / `1.0` for fresh projects, which will throw). Either bump `bundleVersion` to a 3-part value, guard with `parts.Length < 3` at the call site, or harden the method itself with a length guard. Tests calling this against the host editor's `Application.version` should `Assert.Inconclusive` on shorter strings rather than `Assert.Throws` — the throw is a real production bug surface, not a documented contract.
249249

250250
### Editor Introspection (InternalsVisibleTo)
@@ -277,7 +277,7 @@ Services expose minimal `internal` read-only accessors so the Services Explorer
277277
- `MessageBrokerService.Subscribe` rejects static methods; direct `Publish<T>` can throw if the subscription list is mutated during dispatch.
278278
- `DataService.GetData<T>` throws when missing; `LoadData<T>` requires a parameterless constructor.
279279
- Duplicate `PoolService.AddPool<T>` calls throw; `AssetResolverService` requests throw `MissingMemberException` until assets/scenes are registered.
280-
- `VersionInternal`, `Branch`, `Commit`, and `BuildNumber` throw `Exception("Version Data not loaded.")` until version data loads successfully.
280+
- `VersionInternal`, `Branch`, `Commit`, and `BuildNumber` no longer throw — auto-bootstrap + lazy-load make access safe at any phase; missing-Resource cases fall back to `Application.version` / `string.Empty` with a `Debug.LogError`.
281281

282282
## 5. Coding Standards (Unity 6 / C# 9.0)
283283
- **C#**: C# 9.0 syntax; explicit namespaces; no global usings.

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ All notable changes to this package will be documented in this file.
44
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
55
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
66

7+
## [2.1.0] - 2026-05-20
8+
9+
**New**:
10+
- `VersionServices` now auto-bootstraps via `[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]`, populating version metadata before any scene `Awake` callback and before vendor-SDK `SubsystemRegistration` callbacks that read it. Consumers no longer need to call `LoadVersionData()` / `LoadVersionDataAsync()` explicitly for the default flow.
11+
12+
**Changed**:
13+
- Property getters (`VersionInternal`, `Branch`, `Commit`, `BuildNumber`) now lazy-load via a new private `EnsureLoaded()` on first access if the auto-bootstrap hook has not yet fired — protects against undefined ordering between sibling assemblies' `[RuntimeInitializeOnLoadMethod]` callbacks at the same phase.
14+
- Removed the private `IsLoaded()` helper (replaced by `EnsureLoaded()` invoked from each property getter).
15+
16+
**Docs**:
17+
- `docs/version-services.md` rewritten around the new auto-bootstrap contract: the recommended usage no longer includes any explicit load call, the lazy-load fallback is documented, and the Error Reference table now describes the fallback behaviour (no exception is raised).
18+
719
## [2.0.2] - 2026-05-20
820

921
**Fixed**:

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,13 +232,15 @@ cmd.ExecuteCommand(new LevelUpCommand());
232232
### Version Services
233233

234234
```csharp
235-
// Pick one of:
236-
VersionServices.LoadVersionData(); // sync — recommended for tiny version-data.txt payloads
237-
await VersionServices.LoadVersionDataAsync(); // async — use if VersionData embeds large blobs
238-
235+
// No setup call needed — version metadata auto-loads at SubsystemRegistration,
236+
// with a lazy-load fallback on first property access.
239237
string branch = VersionServices.Branch;
240238
string commit = VersionServices.Commit;
241239
string ext = VersionServices.VersionExternal; // always safe, no load needed
240+
241+
// Optional explicit pre-warm (idempotent — no-ops if already loaded):
242+
// VersionServices.LoadVersionData(); // sync, recommended default
243+
// await VersionServices.LoadVersionDataAsync(); // async — only useful for large VersionData blobs
242244
```
243245

244246
### Asset Loading

Runtime/VersionServices.cs

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,35 +31,89 @@ public struct VersionData
3131
public static string VersionExternal => Application.version;
3232

3333
/// <summary>
34-
/// Internal version (M.m.p-b.branch.commit)
34+
/// Internal version (M.m.p-b.branch.commit). Lazy-loads on first access if the
35+
/// <see cref="Bootstrap"/> hook has not yet fired (see remarks on <see cref="Bootstrap"/>);
36+
/// falls back to <see cref="Application.version"/> when the <c>version-data</c> Resource is
37+
/// missing or fails to parse.
3538
/// </summary>
36-
public static string VersionInternal => IsLoaded()
37-
? FormatInternalVersion(_versionData)
38-
: Application.version;
39+
public static string VersionInternal
40+
{
41+
get
42+
{
43+
EnsureLoaded();
44+
return _loaded ? FormatInternalVersion(_versionData) : Application.version;
45+
}
46+
}
3947

4048
/// <summary>
41-
/// Name of the git branch that this app was built from.
49+
/// Name of the git branch that this app was built from. Lazy-loads on first access; returns
50+
/// <see cref="string.Empty"/> when the <c>version-data</c> Resource is missing.
4251
/// </summary>
43-
public static string Branch => IsLoaded() ? _versionData.BranchName : string.Empty;
52+
public static string Branch
53+
{
54+
get
55+
{
56+
EnsureLoaded();
57+
return _loaded ? _versionData.BranchName : string.Empty;
58+
}
59+
}
4460

4561
/// <summary>
46-
/// Short hash of the commit this app was built from.
62+
/// Short hash of the commit this app was built from. Lazy-loads on first access; returns
63+
/// <see cref="string.Empty"/> when the <c>version-data</c> Resource is missing.
4764
/// </summary>
48-
public static string Commit => IsLoaded() ? _versionData.CommitHash : string.Empty;
65+
public static string Commit
66+
{
67+
get
68+
{
69+
EnsureLoaded();
70+
return _loaded ? _versionData.CommitHash : string.Empty;
71+
}
72+
}
4973

5074
/// <summary>
51-
/// Build number for this build of the app.
75+
/// Build number for this build of the app. Lazy-loads on first access; returns
76+
/// <see cref="string.Empty"/> when the <c>version-data</c> Resource is missing.
5277
/// </summary>
53-
public static string BuildNumber => IsLoaded() ? _versionData.BuildNumber : string.Empty;
78+
public static string BuildNumber
79+
{
80+
get
81+
{
82+
EnsureLoaded();
83+
return _loaded ? _versionData.BuildNumber : string.Empty;
84+
}
85+
}
5486

5587
private static VersionData _versionData;
5688
private static bool _loaded;
5789

5890
/// <summary>
59-
/// Load the internal version string from resources synchronously. Should be called once
60-
/// when the app is started. Intended for tiny payloads (the default <c>version-data.txt</c>
61-
/// is a few hundred bytes); use <see cref="LoadVersionDataAsync"/> if <see cref="VersionData"/>
62-
/// is extended with large blobs that would noticeably stall the main thread.
91+
/// Auto-bootstrap hook: populates version metadata at the earliest runtime phase Unity
92+
/// exposes, before any scene <c>Awake</c> and before vendor SDK <c>SubsystemRegistration</c>
93+
/// callbacks that read <see cref="VersionInternal"/> / <see cref="BuildNumber"/> (e.g.
94+
/// Sentry's Option Config Script). Consumers no longer need to call
95+
/// <see cref="LoadVersionData"/> / <see cref="LoadVersionDataAsync"/> explicitly for the
96+
/// default flow.
97+
/// </summary>
98+
/// <remarks>
99+
/// Ordering between <see cref="RuntimeInitializeLoadType.SubsystemRegistration"/> callbacks
100+
/// across assemblies is undefined; if a sibling SDK's hook fires before this one, the
101+
/// property accessors' lazy-load fallback (<see cref="EnsureLoaded"/>) covers the race.
102+
/// </remarks>
103+
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
104+
private static void Bootstrap()
105+
{
106+
LoadVersionData();
107+
}
108+
109+
/// <summary>
110+
/// Load the internal version string from resources synchronously. Auto-invoked by
111+
/// <see cref="Bootstrap"/> at <see cref="RuntimeInitializeLoadType.SubsystemRegistration"/>;
112+
/// safe to call directly for explicit pre-warming. Idempotent — short-circuits via
113+
/// <see cref="EnsureLoaded"/>'s caller when version data is already loaded. Intended for
114+
/// tiny payloads (the default <c>version-data.txt</c> is a few hundred bytes); use
115+
/// <see cref="LoadVersionDataAsync"/> if <see cref="VersionData"/> is extended with large
116+
/// blobs that would noticeably stall the main thread.
63117
/// </summary>
64118
public static void LoadVersionData()
65119
{
@@ -77,8 +131,12 @@ public static void LoadVersionData()
77131
}
78132

79133
/// <summary>
80-
/// Load the internal version string from resources async. Should be called once when the
81-
/// app is started.
134+
/// Load the internal version string from resources async. The synchronous
135+
/// <see cref="LoadVersionData"/> is auto-invoked at
136+
/// <see cref="RuntimeInitializeLoadType.SubsystemRegistration"/>, so callers only need this
137+
/// async variant when explicitly pre-warming off the main thread or when
138+
/// <see cref="VersionData"/> has been extended with large blobs that would noticeably stall
139+
/// the main thread.
82140
/// </summary>
83141
public static async Task LoadVersionDataAsync()
84142
{
@@ -160,9 +218,11 @@ public static string FormatInternalVersion(VersionData data)
160218
return version;
161219
}
162220

163-
private static bool IsLoaded()
221+
private static void EnsureLoaded()
164222
{
165-
return _loaded ? true : throw new Exception("Version Data not loaded.");
223+
if (_loaded) return;
224+
225+
LoadVersionData();
166226
}
167227
}
168228
}

Samples~/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ These are the mistakes most likely to bite first-time users — every sample bel
112112
- **`DataService` keys `PlayerPrefs` by `typeof(T).Name`.** Two types named `PlayerData` in different namespaces will collide.
113113
- **`PoolService.AddPool<T>` throws on duplicate registrations.** One pool per type. Call `RemovePool<T>()` first if you need to re-register.
114114
- **`AssetResolverService.RequestAsset` / `LoadSceneAsync` throw `MissingMemberException` until you call `AddConfigs` / `AddAssets`.** Sample 2 demonstrates the registration step.
115-
- **`VersionServices.VersionInternal` / `Branch` / `Commit` / `BuildNumber` throw until `LoadVersionDataAsync()` completes.** `VersionExternal` is always safe (reads `Application.version`).
115+
- **`VersionServices` auto-bootstraps at `SubsystemRegistration` + lazy-loads on first property access.** `VersionInternal` / `Branch` / `Commit` / `BuildNumber` are safe to read at any time; missing `version-data.txt` returns `Application.version` / `string.Empty` and logs an error. `LoadVersionData()` / `LoadVersionDataAsync()` remain available for explicit pre-warming.
116116
- **`TickService` and `CoroutineService` each create a `DontDestroyOnLoad` GameObject.** Always `Dispose()` them on teardown, otherwise the host objects accumulate across domain reloads. The playground bootstrap uses `MainInstaller.CleanDispose<T>()` for this.
117117
- **`IAsyncCoroutine.StopCoroutine(triggerOnComplete)`** — the parameter is currently not respected. The completion callback fires regardless. Do not rely on cancellation-without-callback semantics.
118118

Tests/EditMode/Unit/VersionServicesSyncLoadTest.cs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System;
21
using System.Reflection;
32
using GameLovers.Services;
43
using NUnit.Framework;
@@ -27,12 +26,16 @@ public void ResetStaticState()
2726
}
2827

2928
[Test]
30-
public void AccessBeforeLoad_Throws()
29+
public void AccessBeforeLoad_AutoLoads()
3130
{
32-
Assert.Throws<Exception>(() => { var _ = VersionServices.VersionInternal; });
33-
Assert.Throws<Exception>(() => { var _ = VersionServices.Branch; });
34-
Assert.Throws<Exception>(() => { var _ = VersionServices.Commit; });
35-
Assert.Throws<Exception>(() => { var _ = VersionServices.BuildNumber; });
31+
Assert.IsFalse((bool)LoadedField.GetValue(null), "Precondition: SetUp resets _loaded to false");
32+
33+
Assert.DoesNotThrow(() => { var _ = VersionServices.VersionInternal; });
34+
Assert.DoesNotThrow(() => { var _ = VersionServices.Branch; });
35+
Assert.DoesNotThrow(() => { var _ = VersionServices.Commit; });
36+
Assert.DoesNotThrow(() => { var _ = VersionServices.BuildNumber; });
37+
38+
Assert.IsTrue((bool)LoadedField.GetValue(null), "Accessor should auto-trigger LoadVersionData via EnsureLoaded");
3639
}
3740

3841
[Test]

Tests/PlayMode/Integration/VersionServicesIntegrationTest.cs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System;
21
using System.Collections;
32
using System.Reflection;
43
using GameLovers.Services;
@@ -26,12 +25,16 @@ public void ResetStaticState()
2625
}
2726

2827
[UnityTest, Order(1)]
29-
public IEnumerator AccessBeforeLoad_Throws()
28+
public IEnumerator AccessBeforeLoad_AutoLoads()
3029
{
31-
Assert.Throws<Exception>(() => { var _ = VersionServices.VersionInternal; });
32-
Assert.Throws<Exception>(() => { var _ = VersionServices.Branch; });
33-
Assert.Throws<Exception>(() => { var _ = VersionServices.Commit; });
34-
Assert.Throws<Exception>(() => { var _ = VersionServices.BuildNumber; });
30+
Assert.IsFalse((bool)LoadedField.GetValue(null), "Precondition: SetUp resets _loaded to false");
31+
32+
Assert.DoesNotThrow(() => { var _ = VersionServices.VersionInternal; });
33+
Assert.DoesNotThrow(() => { var _ = VersionServices.Branch; });
34+
Assert.DoesNotThrow(() => { var _ = VersionServices.Commit; });
35+
Assert.DoesNotThrow(() => { var _ = VersionServices.BuildNumber; });
36+
37+
Assert.IsTrue((bool)LoadedField.GetValue(null), "Accessor should auto-trigger LoadVersionData via EnsureLoaded");
3538

3639
yield return null;
3740
}

0 commit comments

Comments
 (0)