Skip to content

Fix/steam achievement timestamp fix#1254

Draft
phobos665 wants to merge 7 commits into
utkarshdalal:masterfrom
phobos665:fix/steam-achievement-timestamp-fix
Draft

Fix/steam achievement timestamp fix#1254
phobos665 wants to merge 7 commits into
utkarshdalal:masterfrom
phobos665:fix/steam-achievement-timestamp-fix

Conversation

@phobos665
Copy link
Copy Markdown
Contributor

@phobos665 phobos665 commented Apr 19, 2026

Description

This PR fixes 3 bugs in the achievement generator code.

  1. It was not supplying the achievements.json with the correct fields. It required "earned" and "earned_time" for the unlock and unlock time respectively.

  2. We were not getting timestamps due to the fact that the schema for achievements & stats did not provide this. To fix this, I needed to create a new function called "applyEarnedState" which applies the earned state based on the bitIndex match with the raw achievementBlocks from userStatsCallback.

  3. Goldberg requires us to put an achievements.json in the GSE save locations and populate them based on the above 2 fixes.

This now fixes the issue where games would re-trigger achievements on launch, such as Brotato.

Happy for feedback. The big one I'm sure could do with some critique is the file output code.

Out of Scope:

  • In a follow-up branch I will refactor Javasteam "getExpandedAchievements" to give back the correct achievement Ids and timestamps, since it looks like there's a bug there.

Recording

Achievement Fix Video:
https://github.com/user-attachments/assets/dccfc456-ddbf-421e-960b-e26a31c54f06

Type of Change

  • Bug fix
  • Performance / stability improvement
  • Compatibility improvements
  • Other (requires prior approval)

Checklist

  • If I have access to #code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.
  • This change aligns with the current project scope (core functionality, stability, or performance). If not, it has been explicitly approved beforehand.
  • I have attached a recording of the change.
  • I have read and agree to the contribution guidelines in CONTRIBUTING.md.

Summary by cubic

Fixes Steam achievement earned state and per‑achievement timestamps, and writes Goldberg-compatible achievements files so games don’t re-trigger achievements.

  • Bug Fixes
    • Output now uses "earned" (bool) and "earned_time" (unix); per‑achievement times come from AchievementBlocks via applyEarnedState, avoiding the expanded‑achievements bug.
    • SteamService passes userStats.achievementBlocks into generation and writes a Goldberg achievements.json to each GSE save dir; existing files are merged so earned progress isn’t downgraded.

Written for commit 7346f83. Summary will update on new commits.

Summary by CodeRabbit

  • Refactor
    • Achievement generation now uses per-user unlock data to produce accurate earned states and timestamps and writes Goldberg-compatible achievement files into user save directories.
  • Bug Fixes
    • Merging with existing saved achievement files preserves previously earned entries and avoids overwriting user progress; improved logging and error handling during file updates.

@phobos665 phobos665 requested a review from utkarshdalal as a code owner April 19, 2026 17:14
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 19, 2026

📝 Walkthrough

Walkthrough

The generator was refactored into parse → apply earned state → write steps and now accepts server-provided achievementBlocks. SteamService.generateAchievements passes these blocks (or emptyList()), caches the result, then invokes writeGseAchievementFiles(...) before building and persisting achievement_name_to_block.json.

Changes

Cohort / File(s) Summary
Service callsite
app/src/main/java/app/gamenative/service/SteamService.kt
generateAchievements now calls StatsAchievementsGenerator.generateStatsAchievements(schema, configDirectory, achievementBlocks) (passing userStats.achievementBlocks or emptyList()), caches result.achievements/cachedAchievementsAppId, then calls generator.writeGseAchievementFiles(result, getGseSaveDirs(instance!!, appId)) before constructing/persisting achievement_name_to_block.json.
Generator refactor & I/O
app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt
generateStatsAchievements split into parseSchema, applyEarnedState, writeConfigFiles; new signature generateStatsAchievements(schema, configDirectory, achievementBlocks); added writeGseAchievementFiles. Introduces AchievementBlocks handling, Timber logging, changes achievement JSON fields from unlocked/unlockTimestampearned/earned_time, and merges/upgrades Goldberg/GSE achievements.json preserving existing earned entries.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Client as SteamService
    participant Gen as StatsAchievementsGenerator
    participant FS as FileSystem/GSEDirs
    participant Cache as LocalCache

    Client->>Gen: generateStatsAchievements(schema, configDirectory, achievementBlocks)
    Gen->>Gen: parseSchema(schema) → ProcessingResult
    Gen->>Gen: applyEarnedState(result, achievementBlocks)
    Gen->>FS: writeConfigFiles(result, configDirectory)
    Gen-->>Client: ProcessingResult (achievements)
    Client->>Cache: update cachedAchievements / cachedAchievementsAppId
    Client->>Gen: writeGseAchievementFiles(result, getGseSaveDirs(instance, appId))
    Gen->>FS: merge/upgrade Goldberg/GSE `achievements.json` in each save dir
    FS-->>Gen: write confirmations
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I nibbled bytes and parsed each name,
Blocks in my paws, I set earned_time the same.
Files stitched gently in every save place,
A hopping refactor—precision and grace,
I twitch my nose for the new achievement race.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Fix/steam achievement timestamp fix' is vague and uses generic phrasing ('timestamp fix') without clearly conveying what the main achievement was or what specific problem was solved. Revise the title to be more descriptive and specific, such as 'Fix achievement earned state and timestamps for Goldberg compatibility' to better reflect the actual changes.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description thoroughly covers all required sections including a detailed explanation of the three bugs fixed, a recording, correct type of change selection, and completed checklist items.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@phobos665 phobos665 marked this pull request as draft April 19, 2026 17:18
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt (1)

228-230: Remove or lower level of per-achievement INFO log.

This logs one INFO line per unlocked achievement every time writeConfigFiles runs — for games with hundreds of achievements this is quite noisy in logcat and looks like leftover instrumentation from debugging this PR. Consider Timber.d(...) (or removing it) and dropping the custom "ach" tag so it flows through the standard Timber pipeline.

♻️ Suggested change
-            if(ach.unlocked == true){ 
-                Timber.tag("ach").i("Achievement ${ach.name} is unlocked: ${ach.unlocked}, unlockTimestamp: ${ach.unlockTimestamp}, formattedUnlockTime: ${ach.formattedUnlockTime}")
-            }
-
+            if (ach.unlocked == true) {
+                Timber.d("Achievement ${ach.name} unlocked at ${ach.unlockTimestamp}")
+            }
+
             ach.unlocked?.let { outputAch["earned"] = it }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt`
around lines 228 - 230, The per-achievement info log in
StatsAchievementsGenerator (inside writeConfigFiles) uses Timber.tag("ach").i
and produces noisy INFO output; change this to a debug-level log (Timber.d) and
remove the custom "ach" tag so it goes through the normal Timber pipeline (i.e.,
replace Timber.tag("ach").i(...) with a Timber.d(...) call that logs the same
compact info, or remove the log entirely if not needed).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt`:
- Around line 228-230: The per-achievement info log in
StatsAchievementsGenerator (inside writeConfigFiles) uses Timber.tag("ach").i
and produces noisy INFO output; change this to a debug-level log (Timber.d) and
remove the custom "ach" tag so it goes through the normal Timber pipeline (i.e.,
replace Timber.tag("ach").i(...) with a Timber.d(...) call that logs the same
compact info, or remove the log entirely if not needed).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0d89e666-ebd6-4964-a349-90f20c3fa702

📥 Commits

Reviewing files that changed from the base of the PR and between e42e84a and 20eff7b.

📒 Files selected for processing (2)
  • app/src/main/java/app/gamenative/service/SteamService.kt
  • app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt

@phobos665 phobos665 marked this pull request as ready for review April 19, 2026 17:31
@phobos665
Copy link
Copy Markdown
Contributor Author

All the code is now there.

Will put in some testing, might take a bit due to there not being any tests in this file yet.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt (2)

389-418: GSE merge logic is correct; a few small polish suggestions.

The "only write when not already earned" guard correctly prevents a server wipe from clearing a locally-earned achievement, which is the main goal of the fix. A few optional cleanups:

  • Fully-qualified org.json.JSONObject is used five times here while the rest of this file avoids it. Consider importing it for readability.
  • Line 392: if mkdirs() fails (e.g., path not writable under the Wine prefix before it's initialized), the subsequent achFile.writeText will throw and be caught, but you'll lose the earned state for that directory silently. A Timber.w when !gseDir.exists() && !gseDir.mkdirs() would make this diagnosable.
  • Line 408: ach.unlockTimestamp?.toLong() ?: 0L will write earned_time: 0 for every non-earned achievement on a first run with no server data. That matches the PR objective (create the file so Goldberg doesn't re-trigger), but downstream parsers that do json.optLong("earned_time", -1) would need to treat 0 as "never". Worth a brief comment near line 408 documenting the sentinel.
Import-level cleanup
 import `in`.dragonbra.javasteam.steam.handlers.steamuserstats.AchievementBlocks
 import java.io.File
 import timber.log.Timber
+import org.json.JSONObject
-                val existing = if (achFile.exists()) {
-                    try {
-                        org.json.JSONObject(achFile.readText(Charsets.UTF_8))
-                    } catch (e: Exception) {
-                        Timber.w(e, "Failed to parse existing GSE achievements.json in ${gseDir.absolutePath}, starting fresh")
-                        org.json.JSONObject()
-                    }
-                } else {
-                    org.json.JSONObject()
-                }
+                val existing = if (achFile.exists()) {
+                    try {
+                        JSONObject(achFile.readText(Charsets.UTF_8))
+                    } catch (e: Exception) {
+                        Timber.w(e, "Failed to parse existing GSE achievements.json in ${gseDir.absolutePath}, starting fresh")
+                        JSONObject()
+                    }
+                } else {
+                    JSONObject()
+                }
                 for (ach in result.achievements) {
-                    val entry = existing.optJSONObject(ach.name) ?: org.json.JSONObject()
+                    val entry = existing.optJSONObject(ach.name) ?: JSONObject()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt`
around lines 389 - 418, In writeGseAchievementFiles: replace repeated
fully-qualified org.json.JSONObject with an import of JSONObject for
readability; after attempting to create the directory (gseDir.mkdirs()), add a
Timber.w when the directory still doesn't exist (!gseDir.exists() &&
!gseDir.mkdirs()) so failures to create the save dir are diagnosable; and add a
brief inline comment next to the earned_time assignment (the
ach.unlockTimestamp?.toLong() ?: 0L expression) documenting that 0 is used as
the "never earned / sentinel" value for downstream parsers that call
optLong(..., -1).

171-193: Out-of-range bitIndex could silently mask schema mismatches; consider adding a warning log.

The applyEarnedState logic correctly handles edge cases:

  1. Duplicate achievementId entries (line 178): The associate function silently drops duplicates, but the Steam API practically guarantees unique achievementId per block list, so deduplication is unnecessary.

  2. Out-of-range bitIndex (line 185): The getOrElse(bitIndex) { 0 } safely returns 0 (treated as locked/unearned). However, this silently masks potential schema or blocks desync. Adding a Timber.w() when bitIndex >= unlockTimes.size would improve visibility without changing behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt`
around lines 171 - 193, In applyEarnedState, add a warning when the computed
bitIndex is outside the unlockTimes list to avoid silently masking schema/desync
issues: when resolving val (blockId, bitIndex) from result.nameToBlockBit and
fetching unlockTimes from blockUnlockTimes, check if bitIndex >=
unlockTimes.size and call Timber.w(...) with context (achievement name, blockId,
bitIndex, unlockTimes.size) before falling back to treating the bit as locked;
keep current behavior (using 0/unlocked=false) but emit the warning to aid
debugging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt`:
- Around line 228-230: The info-level log inside generateStatsAchievements (the
Timber.tag("ach").i(...) call for ach where ach.unlocked == true) is too noisy
and uses an inconsistent tag; change it to a debug-level log and use the
existing "GenerateAchievements" tag (or remove it entirely if not needed).
Locate the block that checks ach.unlocked in
StatsAchievementsGenerator.generateStatsAchievements and replace the
Timber.tag("ach").i(...) invocation with
Timber.tag("GenerateAchievements").d(...) (or delete the logging line) so
unlocked achievements no longer spam INFO-level logs.

---

Nitpick comments:
In `@app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt`:
- Around line 389-418: In writeGseAchievementFiles: replace repeated
fully-qualified org.json.JSONObject with an import of JSONObject for
readability; after attempting to create the directory (gseDir.mkdirs()), add a
Timber.w when the directory still doesn't exist (!gseDir.exists() &&
!gseDir.mkdirs()) so failures to create the save dir are diagnosable; and add a
brief inline comment next to the earned_time assignment (the
ach.unlockTimestamp?.toLong() ?: 0L expression) documenting that 0 is used as
the "never earned / sentinel" value for downstream parsers that call
optLong(..., -1).
- Around line 171-193: In applyEarnedState, add a warning when the computed
bitIndex is outside the unlockTimes list to avoid silently masking schema/desync
issues: when resolving val (blockId, bitIndex) from result.nameToBlockBit and
fetching unlockTimes from blockUnlockTimes, check if bitIndex >=
unlockTimes.size and call Timber.w(...) with context (achievement name, blockId,
bitIndex, unlockTimes.size) before falling back to treating the bit as locked;
keep current behavior (using 0/unlocked=false) but emit the warning to aid
debugging.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 66167545-448b-4503-ae35-9960565963bd

📥 Commits

Reviewing files that changed from the base of the PR and between 20eff7b and f918a28.

📒 Files selected for processing (2)
  • app/src/main/java/app/gamenative/service/SteamService.kt
  • app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt

Comment thread app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt Outdated
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/service/SteamService.kt (1)

2853-2862: Consider guarding on userStats.result == EResult.OK before generating/writing.

storeAchievementUnlocks explicitly checks if (userStats.result != EResult.OK) and throws (line 2984–2986), but generateAchievements does not. If getUserStats fails (offline, transient error), the schema would be invalid/empty, causing parseSchema to return empty results with nameToBlockBit.isEmpty() = true. This prevents achievement_name_to_block.json from being written (it's only written if nameToBlockBit.isNotEmpty() at line 2866), yet writeGseAchievementFiles still executes unconditionally, materializing achievements.json files in GSE directories with all achievements locked (earned=false).

On the next syncAchievementsFromGoldberg call, findSteamSettingsDir returns null (since the mapping file doesn't exist) and the sync is silently skipped, leaving achievements in a locked state despite the PR's intent to populate earned state from the server. The existing-entry preservation in writeGseAchievementFiles prevents data loss, but a failed stats fetch still produces inconsistent state where the achievements file exists but the mapping doesn't.

Suggested guard
             val userStats = instance?._steamUserStats!!.getUserStats(appId, steamUser.steamID!!).await()
+            if (userStats.result != EResult.OK) {
+                Timber.w("generateAchievements: getUserStats returned ${userStats.result} for appId=$appId, skipping")
+                return
+            }
             val schemaArray = userStats.schema.toByteArray()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/service/SteamService.kt` around lines 2853 -
2862, The code calls getUserStats(...).await() and immediately uses its schema
and passes the result to StatsAchievementsGenerator.generateStatsAchievements
and writeGseAchievementFiles; add a guard that checks userStats.result ==
EResult.OK (the same check used in storeAchievementUnlocks) and if not OK, skip
generation/writing (or throw/return) to avoid creating achievements.json without
the mapping; specifically, before calling generator.generateStatsAchievements
and before assigning cachedAchievements/cachedAchievementsAppId and
generator.writeGseAchievementFiles, validate userStats.result, and only proceed
when it equals EResult.OK.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/src/main/java/app/gamenative/service/SteamService.kt`:
- Around line 2853-2862: The code calls getUserStats(...).await() and
immediately uses its schema and passes the result to
StatsAchievementsGenerator.generateStatsAchievements and
writeGseAchievementFiles; add a guard that checks userStats.result == EResult.OK
(the same check used in storeAchievementUnlocks) and if not OK, skip
generation/writing (or throw/return) to avoid creating achievements.json without
the mapping; specifically, before calling generator.generateStatsAchievements
and before assigning cachedAchievements/cachedAchievementsAppId and
generator.writeGseAchievementFiles, validate userStats.result, and only proceed
when it equals EResult.OK.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dd5dc0da-e790-437b-b308-98b3af7ab33d

📥 Commits

Reviewing files that changed from the base of the PR and between f918a28 and 4e4df75.

📒 Files selected for processing (1)
  • app/src/main/java/app/gamenative/service/SteamService.kt

}

fun generateStatsAchievements(schema: ByteArray, configDirectory: String): ProcessingResult {
fun parseSchema(schema: ByteArray): ProcessingResult {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored for easier testing

cachedAchievementsAppId = appId

// Create AchievementFile for each GSE save directory so that they can be tracked by goldberg
generator.writeGseAchievementFiles(result, getGseSaveDirs(instance!!, appId))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creates achievement files in the GSE save directories so GB can take it up.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we were already doing this though. Please check.
The default gse saves folder has changed to C:\Program Files (x86)\Steam\userdata. Please double check that we are writing there and that it works.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will double-check, before my changes I believe this file was absent. But will get back to you and confirm the diff

Comment on lines +228 to +229
ach.unlocked?.let { outputAch["earned"] = it }
ach.unlockTimestamp?.let { outputAch["earned_time"] = it }
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to use earned and earned_time as that's what's required by gb.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt (1)

385-414: Minor defensive polish in writeGseAchievementFiles.

The merge logic (don't downgrade an already-earned entry) is correct. A couple of small, optional improvements:

  1. Guard against an empty ach.nameparseSchema defaults the name to "" when ach["name"] is null, which would create a bogus "" key in the written JSON.
  2. org.json.JSONObject is used fully qualified three times; a top-of-file import org.json.JSONObject would read more cleanly.
  3. Existing keys not present in the current schema are preserved as-is (neither pruned nor re-written). This is likely intentional to survive schema churn, but worth confirming — if the intent is to keep the file canonical to the current schema, you'd want to drop unknown keys.
♻️ Proposed tweak
-import `in`.dragonbra.javasteam.steam.handlers.steamuserstats.AchievementBlocks
 import java.io.File
 import timber.log.Timber
+import org.json.JSONObject
+import `in`.dragonbra.javasteam.steam.handlers.steamuserstats.AchievementBlocks
@@
     fun writeGseAchievementFiles(result: ProcessingResult, gseSaveDirs: List<File>) {
         for (gseDir in gseSaveDirs) {
             try {
                 if (!gseDir.exists()) gseDir.mkdirs()
                 val achFile = File(gseDir, "achievements.json")
                 val existing = if (achFile.exists()) {
                     try {
-                        org.json.JSONObject(achFile.readText(Charsets.UTF_8))
+                        JSONObject(achFile.readText(Charsets.UTF_8))
                     } catch (e: Exception) {
                         Timber.w(e, "Failed to parse existing GSE achievements.json in ${gseDir.absolutePath}, starting fresh")
-                        org.json.JSONObject()
+                        JSONObject()
                     }
                 } else {
-                    org.json.JSONObject()
+                    JSONObject()
                 }
                 for (ach in result.achievements) {
-                    val entry = existing.optJSONObject(ach.name) ?: org.json.JSONObject()
+                    if (ach.name.isEmpty()) continue
+                    val entry = existing.optJSONObject(ach.name) ?: JSONObject()
                     if (!entry.optBoolean("earned", false)) {
                         entry.put("earned", ach.unlocked == true)
                         entry.put("earned_time", ach.unlockTimestamp?.toLong() ?: 0L)
                     }
                     existing.put(ach.name, entry)
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt`
around lines 385 - 414, The writeGseAchievementFiles method may create a bogus
empty-string key when achievement.name is empty and repeats fully-qualified
org.json.JSONObject; fix by skipping any achievement with a blank name (guarding
ach.name.isNullOrBlank() before using it) and add a file-level import for
org.json.JSONObject to replace fully-qualified references (replace
org.json.JSONObject() with JSONObject()). Also decide whether to prune unknown
existing keys: if you want the file canonical to current schema, filter existing
keys to only include names present in result.achievements before writing;
otherwise leave preservation behavior as-is.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt`:
- Around line 385-414: The writeGseAchievementFiles method may create a bogus
empty-string key when achievement.name is empty and repeats fully-qualified
org.json.JSONObject; fix by skipping any achievement with a blank name (guarding
ach.name.isNullOrBlank() before using it) and add a file-level import for
org.json.JSONObject to replace fully-qualified references (replace
org.json.JSONObject() with JSONObject()). Also decide whether to prune unknown
existing keys: if you want the file canonical to current schema, filter existing
keys to only include names present in result.achievements before writing;
otherwise leave preservation behavior as-is.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0d6c837a-6d0c-4900-a063-76dd402ba0fe

📥 Commits

Reviewing files that changed from the base of the PR and between 4e4df75 and 7346f83.

📒 Files selected for processing (1)
  • app/src/main/java/app/gamenative/statsgen/StatsAchievementsGenerator.kt

@phobos665 phobos665 marked this pull request as draft April 21, 2026 21:47
@joshuatam
Copy link
Copy Markdown
Contributor

joshuatam commented Apr 26, 2026

How about a much simpler concept:

  1. pre-populate the achivements.json on first run (or if file not exits), with the userStats.stats to preload user's already received value
userStats.stats.forEach { stat ->
    stat.statId
    stat.statValue
}
  1. this achivements.json is put into "${ImageFs.WINEPREFIX}/drive_c/Program Files (x86)/Steam/userdata/$accountId/$appId/" which is the second item of SteamService.getGseSaveDirs()

For example, Vampire Survivors, the achievements.json has the following content:

{
    "Banish10Items": {
        "earned": false,
        "earned_time": 0
    },
}

Based on the userStats.stats, it is already good to hard code earned = true and earned_time = the timestamp when generating the file, unless we want to view the precise achievement time later, we can make a later update for this.

@phobos665
Copy link
Copy Markdown
Contributor Author

How about a much simpler concept:

  1. pre-populate the achivements.json on first run (or if file not exits), with the userStats.stats to preload user's already received value
userStats.stats.forEach { stat ->
    stat.statId
    stat.statValue
}
  1. this achivements.json is put into "${ImageFs.WINEPREFIX}/drive_c/Program Files (x86)/Steam/userdata/$accountId/$appId/" which is the second item of SteamService.getGseSaveDirs()

For example, Vampire Survivors, the achievements.json has the following content:

{
    "Banish10Items": {
        "earned": false,
        "earned_time": 0
    },
}

Based on the userStats.stats, it is already good to hard code earned = true and earned_time = the timestamp when generating the file, unless we want to view the precise achievement time later, we can make a later update for this.

So my worry about the timestamp was due to potentially getting invalidated in future. But very happy to see if we can go down a much simpler route.

I believe that my JavaSteam implementation is bugged, and I'd like to ideally move the complexity back into that rather than us having to do complex processing with in GN, when it's clearly the domain of JavaSteam to give back rich achievements & stats correctly. (I tried my getExpandedAchievements function, but it clearly has a bug going on which blocks us from just using that).

Thoughts?

@utkarshdalal
Copy link
Copy Markdown
Owner

Is this still being worked on?

@phobos665
Copy link
Copy Markdown
Contributor Author

Is this still being worked on?

Going to keep working on this, I think the approach can be made simpler based on some of JT's feedback here.

Will get back to you on this one in the next couple of days.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants