Compare commits

...

168 Commits

Author SHA1 Message Date
Graham dfb1f3c0e6 Skip containers that already exist during import 1 year ago
Graham 28a9667471 Add support for resolving files/groups between old and new engine caches 1 year ago
Graham 97b53c5695 Remove use of wildcard import 1 year ago
Graham a5dfbdd691 Remove PolarKeyDownloader 1 year ago
Graham 5b8cd2964f Download names from Pazaz's repository 1 year ago
Graham 8cd73a926b Remove redundant type parameter 2 years ago
Graham 69ea1ac7ab Add beta HTTP js5 endpoint 2 years ago
Graham 9463a70520 Catch KeyDownloader::getMissingUrls exceptions 2 years ago
Graham 33ecd68654 Simplify CORS handling 2 years ago
Graham ddfc472c84 Switch to Ktor's Jetty backend 2 years ago
Graham bf30f1e4a5 Update dependencies 2 years ago
Graham 6c52f5f48f Update Kotlinter 2 years ago
Graham 6d0f28d0fa Update dependencies 2 years ago
Graham 5604811e8b Update Kotlin 2 years ago
Graham c13f131c32 Update Gradle 2 years ago
Graham d463ffa4d7 Update dependencies 2 years ago
Graham 962716524e Fix lint error 2 years ago
Graham 7d51312e57 Revert "Enforce consistent version of Kotlin's stdlib across the whole project" 2 years ago
Graham 54f2a44eab Move assignment outside if 2 years ago
Graham 12eba96055 Update copyright year 2 years ago
Graham ba5c285a47 Remove defunct OpenOSRS key downloader and link 2 years ago
Graham ea9ec62e6e Add support for fetching master index from the API 2 years ago
Graham 9d3282ca3a Simplify reference counting in Js5Service 2 years ago
Graham 62df015ad5 Always sort empty rows to the bottom of the table 2 years ago
Graham 55072a5102 Sort build column numerically 2 years ago
Graham 97ca5cbc2f Update dependencies 2 years ago
Graham 4ef349d0ac Add CoroutineExceptionHandler to LoginChannelHandler 2 years ago
Graham 0814443bc5 Add total size of all caches to the caches page 2 years ago
Graham 2d7b235f15 Add support for the new OSRS short code map format 2 years ago
Graham 2c70a7c1ec Add methods to allow reading files by group name and file ID 2 years ago
Graham c1704fc111 Exclude Logback from minimization 2 years ago
Graham 8f4d28393e Download XTEA keys from HDOS 2 years ago
Graham db1ecf3c00 Read after sending ExchangeSessionKey to the client 2 years ago
Graham 655b9c9cf7 Update dependencies 2 years ago
Graham ba0bd2ca3a Use 4 space indents in .sql files 2 years ago
Graham eebb54dd60 Disable formatting in all migrations 2 years ago
Graham 4ca7fab636 Add blank line between is blocks 2 years ago
Graham c0056f9cb1 Update kotlinter 2 years ago
Graham d63af29679 Fix linter error 2 years ago
Graham fd2545bc9d Format OpenNxtStore 2 years ago
Graham dc8fcd09f6 Flesh out LoginChannelHandler 2 years ago
Graham cf7c05441c Update dependencies 2 years ago
Graham 091c8ee1ca Update dependencies 2 years ago
Graham d0a46dc5e5 Removing loading requirements from the NXT downloader 2 years ago
Graham 4cc83e6316 Mark all methods in a final class as non-final 2 years ago
Graham aff58e5e73 Split FinalTransformer into Final{Class,Method}Transformer 2 years ago
Graham 39d2f18cca Add tool for unpacking OpenNXT caches 2 years ago
Graham dbb30e0bd8 Disable fsync in FlatFileStore 2 years ago
Graham 4aa181b91a Add support for disabling fsync in atomicWrite 2 years ago
Graham 4252bf0dbc Update Gradle 2 years ago
Graham c3c240b4e6 Cache the /caches.json endpoint for 15 minutes 2 years ago
Graham ef2919761d Add method for peeking at the version trailer 2 years ago
Graham 5ac5ae76f3 Update dependencies 2 years ago
Graham 5e4305b0f3 Refactor more code 2 years ago
Graham d80de942e0 Fix womanWear1/2 naming 2 years ago
Graham 0d87057ae6 Rename more classes 2 years ago
Graham 3841d39fe8 Refactor more TextureOps 2 years ago
Graham e2ceef0a32 Fix case 2 years ago
Graham 2abd1d7ea0 Fix CreateAccountCodec padding 2 years ago
Graham 76e7e93f3c Allocate buffer exactly in NameSuggestionsCodec 2 years ago
Graham fa41b48f1a Add all create responses 2 years ago
Graham f31b2519f9 Add all login responses except OK 2 years ago
Graham 827e6262a9 Rename 'Display video advertisement' to 'Show video advertisement' 2 years ago
Graham e84a58a36b Reformat tables in the glossary 2 years ago
Graham 4e6f5c360d Add StaffModLevel to the glossary 2 years ago
Graham 0a5e2343c1 Disambiguate create invalid password responses 2 years ago
Graham 851ef8e4e9 Separate LoginResponse and Js5LoginResponse 2 years ago
Graham e4b5f8b850 Check there are no trailing bytes in Rs2Decoder 2 years ago
Graham 0a99813932 Don't use LocalDate to represent date of birth in packets 2 years ago
Graham 650e298bc9 Add CREATE_ACCOUNT packet 2 years ago
Graham fef6441889 Fix encryption of CHECK_WORLD_SUITABILITY packet 2 years ago
Graham 72e259c8ad Fix length of CREATE_ACCOUNT packet 2 years ago
Graham 1bb244b7f7 Move length encoding/decoding from Rs2{Decoder,Encoder} to PacketCodec 2 years ago
Graham 431685124a Escape greater/less than symbols incorrectly interpreted as tags 2 years ago
Graham 6e41863c58 Add create protocol documentation 2 years ago
Graham cc8193eca4 Fix typo 2 years ago
Graham 02d39297cb Sort protocols 2 years ago
Graham 892a69df03 Fix cell alignment 2 years ago
Graham 537b158928 Add CREATE_CHECK_NAME packet 2 years ago
Graham 7c6ccbf556 Add CreateCheckDateOfBirthCountry packet 2 years ago
Graham 9e969d8dfa Add CHECK_WORLD_SUITABILITY packet 2 years ago
Graham 4c309a0f50 Split protocol packages into upstream/downstream packages 2 years ago
Graham 73defefef4 Create codecs with dependency injection 2 years ago
Graham af3477776c Fix ktor dependency names 2 years ago
Graham 55ecf7a037 Add missing Guice API dependency to the archive module 2 years ago
Graham 48944d6bac Update login packet field descriptions based on official packet names 2 years ago
Graham c01933614c Bind Jackson module consistently with other Guice modules 2 years ago
Graham 4604bc8b81 Add assisted injection extension 2 years ago
Graham 3fe7bdcedc Optimise CloseableInjector 2 years ago
Graham c43d48f71b Rename LOGIN_DOWNSTREAM_JS5REMOTE to JS5REMOTE_DOWNSTREAM 2 years ago
Graham 92c41d4a19 Update dependencies 2 years ago
Graham 80551adeef Improve consistency of protocol documentation 2 years ago
Graham 0666df686c Add Base37 implementation 2 years ago
Graham 0c2108d750 Add separate Protocol for INIT_JS5REMOTE_CONNECTION responses 2 years ago
Graham e90513aa36 Update to Ktor 2 2 years ago
Graham b665b9a359 Replace TODO with "Verify ID" in the login protocol documentation 2 years ago
Graham 4254b34b7d Update Gradle 2 years ago
Graham a590a80190 Add list of all game packets 2 years ago
Graham 3e39875b8c Refactor more code 2 years ago
Graham 67f3dbaf57 Update dependencies 2 years ago
Graham 58c943e9e6 Add missing thousands separator in coordinate system documentation 2 years ago
Graham ab248bb267 Fix small mistake in coordinate system documentation 2 years ago
Graham 4eea6d752a Don't terminate if downloading keys from a source fails 2 years ago
Graham e804fdc065 Add Cache-Control and ETag headers to the exportGroup endpoint 2 years ago
Graham 9c9a1ecf39 Add archive API documentation 2 years ago
Graham 617263f064 Update dependencies 2 years ago
Graham 21560b1afd Ignore fsync on directory failures 3 years ago
Graham f7e194dfa6 Improve atomicWrite 3 years ago
Graham 2292b9449c Ensure flow obstructor initializers always read a static field 3 years ago
Graham 2b720af6e5 Fix line length 3 years ago
Graham 3e2d6ee0ec Fix right-aligned columns 3 years ago
Graham 0158bc937b Add archive/index sizes and completion percentages to legacy cache pages 3 years ago
Graham aa316e273d Split version_list_stats table by index 3 years ago
Graham ee61999c6f Ensure size and block columns in index_stats are always non-NULL 3 years ago
Graham 6f30aebb22 Improve index table row switch order 3 years ago
Graham c85e8ed873 Remove unused import 3 years ago
Graham 2aaa19e05e Format CacheExporter 3 years ago
Graham f5b9f269f6 Add per-archive stats to the cache pages 3 years ago
Graham ce9604a28d Allow cross-origin requests to the archive 3 years ago
Graham c94678c7c5 Add API for downloading individual groups 3 years ago
Graham e5512cbdf6 Update number of loading requirements 3 years ago
Graham c3383aed11 Fix index_stats rows for empty indexes 3 years ago
Graham 339f1d504b Update dependencies 3 years ago
Graham ba573312dd Add missing JOIN condition to index_stats view 3 years ago
Graham d422934661 Add Jagex loc shape names to the glossary 3 years ago
Graham 3eefa3df52 Fix downloading caches 3 years ago
Graham af918cf535 Add support for hiding broken caches 3 years ago
Graham 80dda3f2dc Add missing JOIN condition 3 years ago
Graham d186f5aef4 Add initial support for separate scopes to the archiving service 3 years ago
Graham 2c31776c54 Update dependencies 3 years ago
Graham 7abb995461 Update Kotlin 3 years ago
Graham 88ef8aec92 Fix reading timestamps with sign bit set 3 years ago
Graham ddecca5d0b Update dependencies 3 years ago
Graham 804ae70def Format refreshViews() in CacheImporter 3 years ago
Graham fe69594180 Add command for extracting caches found with Edward's cache finder 3 years ago
Graham 03e6c3dd81 Annotate crypto methods with @Jvm{Overloads,Static} 3 years ago
Graham 26651618ef Optimise uncompression of encrypted groups with invalid keys 3 years ago
Graham caaddad0ed Flush underlying Store when a Cache is flushed 3 years ago
Graham b320348ec7 Fix bug where music data file was closed on flush 3 years ago
Graham bcc2fdf48e Add @JvmStatic and @JvmOverloads to more cache methods 3 years ago
Graham bf12f41faf Update run configurations 3 years ago
Graham 6f7350afa3 Fix JagArchive tests if libbzip2 is available 3 years ago
Graham 078b1f6197 Fix version trailers in RuneLiteStore 3 years ago
Graham 52ca09e4d0 Exclude JNR from minimization 3 years ago
Graham b7be7950c6 Update GeneratedByteBufExtensions 3 years ago
Graham 7859b929ad Fix buffer-generator mainClass name 3 years ago
Graham 4e19879ee0 Fix use of deprecated method in ByteBufExtensionGenerator 3 years ago
Graham 712d874848 Fix Jackson version 3 years ago
Graham a885695fdf Update dependencies 3 years ago
Graham 3e60a82ca1 Add command for unpacking RuneLite flatcaches 3 years ago
Graham ba75306169 Annotate cache methods with default parameters with @JvmOverloads 3 years ago
Graham aa2784a9e6 Add class for converting RuneLite flatcaches to other formats 3 years ago
Graham 8c415023af Update Gradle 3 years ago
Graham f7abf23dee Update dependencies 3 years ago
Graham c21895f052 Add JNR-based bzip2 implementation compatible with Jagex's 3 years ago
Graham a6dbb29ea0 Fix dependency order 3 years ago
Graham c9f397759e Use advisory locks to prevent concurrent view refreshes 3 years ago
Graham 35f54fd753 Skip corrupt archives when importing legacy caches 3 years ago
Graham 29716379c3 Update Gradle 3 years ago
Graham 1d76d90bcb Update dependencies 3 years ago
Graham 5c77ee4bd2 Format CacheExporter 3 years ago
Graham 1a78ef3c7d Throw an exception if header is truncated 3 years ago
Graham 73eb30dbf9 Add game, environment, language, build and timestamp to file names 3 years ago
Graham b99ae4bb09 Update dependencies 3 years ago
Graham fb0d7ef923 Add link to the RuneScape Archive 3 years ago
Graham 799277b386 Simplify JagexGzipOutputStream 3 years ago
Graham 0d097f7d2c Update Gradle 3 years ago
Graham 57bdd6c0f4 Update dependencies 3 years ago
  1. 6
      .editorconfig
  2. 9
      .idea/runConfigurations/AstDeobfuscator.xml
  3. 9
      .idea/runConfigurations/BytecodeDeobfuscator.xml
  4. 8
      .idea/runConfigurations/Decompiler.xml
  5. 8
      .idea/runConfigurations/Deobfuscator.xml
  6. 8
      .idea/runConfigurations/GameServer.xml
  7. 9
      .idea/runConfigurations/GenerateBuffer.xml
  8. 9
      .idea/runConfigurations/Patcher.xml
  9. 2
      LICENSE
  10. 3
      all/build.gradle.kts
  11. 2
      all/src/main/kotlin/org/openrs2/Command.kt
  12. 1
      archive/build.gradle.kts
  13. 12
      archive/src/main/kotlin/org/openrs2/archive/ArchiveModule.kt
  14. 3
      archive/src/main/kotlin/org/openrs2/archive/cache/CacheCommand.kt
  15. 17
      archive/src/main/kotlin/org/openrs2/archive/cache/CacheDownloader.kt
  16. 341
      archive/src/main/kotlin/org/openrs2/archive/cache/CacheExporter.kt
  17. 222
      archive/src/main/kotlin/org/openrs2/archive/cache/CacheImporter.kt
  18. 16
      archive/src/main/kotlin/org/openrs2/archive/cache/CrossPollinateCommand.kt
  19. 223
      archive/src/main/kotlin/org/openrs2/archive/cache/CrossPollinator.kt
  20. 5
      archive/src/main/kotlin/org/openrs2/archive/cache/ExportCommand.kt
  21. 15
      archive/src/main/kotlin/org/openrs2/archive/cache/Js5ChannelHandler.kt
  22. 4
      archive/src/main/kotlin/org/openrs2/archive/cache/NxtJs5ChannelHandler.kt
  23. 24
      archive/src/main/kotlin/org/openrs2/archive/cache/OsrsJs5ChannelHandler.kt
  24. 7
      archive/src/main/kotlin/org/openrs2/archive/cache/OsrsJs5ChannelInitializer.kt
  25. 149
      archive/src/main/kotlin/org/openrs2/archive/cache/finder/CacheFinderExtractor.kt
  26. 25
      archive/src/main/kotlin/org/openrs2/archive/cache/finder/ExtractCommand.kt
  27. 10
      archive/src/main/kotlin/org/openrs2/archive/cache/nxt/InitJs5RemoteConnectionCodec.kt
  28. 25
      archive/src/main/kotlin/org/openrs2/archive/cache/nxt/Js5OkCodec.kt
  29. 1
      archive/src/main/kotlin/org/openrs2/archive/cache/nxt/Js5RequestEncoder.kt
  30. 6
      archive/src/main/kotlin/org/openrs2/archive/cache/nxt/Js5ResponseDecoder.kt
  31. 7
      archive/src/main/kotlin/org/openrs2/archive/cache/nxt/LoginResponse.kt
  32. 3
      archive/src/main/kotlin/org/openrs2/archive/game/Game.kt
  33. 5
      archive/src/main/kotlin/org/openrs2/archive/game/GameDatabase.kt
  34. 2
      archive/src/main/kotlin/org/openrs2/archive/jav/JavConfig.kt
  35. 57
      archive/src/main/kotlin/org/openrs2/archive/key/HdosKeyDownloader.kt
  36. 2
      archive/src/main/kotlin/org/openrs2/archive/key/JsonKeyReader.kt
  37. 6
      archive/src/main/kotlin/org/openrs2/archive/key/KeyImporter.kt
  38. 3
      archive/src/main/kotlin/org/openrs2/archive/key/KeySource.kt
  39. 19
      archive/src/main/kotlin/org/openrs2/archive/key/OpenOsrsKeyDownloader.kt
  40. 50
      archive/src/main/kotlin/org/openrs2/archive/key/PolarKeyDownloader.kt
  41. 107
      archive/src/main/kotlin/org/openrs2/archive/map/MapRenderer.kt
  42. 12
      archive/src/main/kotlin/org/openrs2/archive/name/RuneStarNameDownloader.kt
  43. 152
      archive/src/main/kotlin/org/openrs2/archive/web/CachesController.kt
  44. 8
      archive/src/main/kotlin/org/openrs2/archive/web/KeysController.kt
  45. 120
      archive/src/main/kotlin/org/openrs2/archive/web/WebServer.kt
  46. 176
      archive/src/main/resources/org/openrs2/archive/migrations/V12__scopes.sql
  47. 2
      archive/src/main/resources/org/openrs2/archive/migrations/V13__hidden_flag.sql
  48. 95
      archive/src/main/resources/org/openrs2/archive/migrations/V14__scopes_fix.sql
  49. 95
      archive/src/main/resources/org/openrs2/archive/migrations/V15__empty_index_stats.sql
  50. 95
      archive/src/main/resources/org/openrs2/archive/migrations/V16__empty_index_size.sql
  51. 53
      archive/src/main/resources/org/openrs2/archive/migrations/V17__split_version_list_stats.sql
  52. 2
      archive/src/main/resources/org/openrs2/archive/migrations/V18__hdos.sql
  53. 3
      archive/src/main/resources/org/openrs2/archive/migrations/V19__source_type_cross_pollination.sql
  54. 7
      archive/src/main/resources/org/openrs2/archive/migrations/V20__cross_pollination.sql
  55. 1
      archive/src/main/resources/org/openrs2/archive/migrations/V2__game_url.sql
  56. 1
      archive/src/main/resources/org/openrs2/archive/migrations/V5__keys.sql
  57. 43
      archive/src/main/resources/org/openrs2/archive/static/js/openrs2.js
  58. 285
      archive/src/main/resources/org/openrs2/archive/templates/api/index.html
  59. 30
      archive/src/main/resources/org/openrs2/archive/templates/caches/index.html
  60. 104
      archive/src/main/resources/org/openrs2/archive/templates/caches/show.html
  61. 3
      archive/src/main/resources/org/openrs2/archive/templates/index.html
  62. 4
      archive/src/main/resources/org/openrs2/archive/templates/layout.html
  63. 2
      asm/build.gradle.kts
  64. 2
      asm/src/main/kotlin/org/openrs2/asm/ClassNodeRemapper.kt
  65. 2
      asm/src/main/kotlin/org/openrs2/asm/InsnNodeUtils.kt
  66. 2
      asm/src/main/kotlin/org/openrs2/asm/MethodNodeUtils.kt
  67. 3
      asm/src/main/kotlin/org/openrs2/asm/StackMetadata.kt
  68. 1
      asm/src/main/kotlin/org/openrs2/asm/filter/Glob.kt
  69. 2
      asm/src/main/kotlin/org/openrs2/asm/packclass/ConstantPool.kt
  70. 24
      asm/src/main/kotlin/org/openrs2/asm/packclass/PackClass.kt
  71. 2
      buffer-generator/build.gradle.kts
  72. 2
      buffer-generator/src/main/kotlin/org/openrs2/buffer/generator/ByteBufExtensionGenerator.kt
  73. 1
      buffer-generator/src/main/kotlin/org/openrs2/buffer/generator/ByteOrder.kt
  74. 2
      buffer/build.gradle.kts
  75. 12
      build.gradle.kts
  76. 2
      cache-550/build.gradle.kts
  77. 2
      cache-550/src/main/kotlin/org/openrs2/cache/config/enum/EnumType.kt
  78. 3
      cache-550/src/main/kotlin/org/openrs2/cache/config/struct/StructType.kt
  79. 1
      cache-550/src/main/kotlin/org/openrs2/cache/config/varbit/VarbitType.kt
  80. 19
      cache-550/src/main/kotlin/org/openrs2/cache/midi/Song.kt
  81. 2
      cache-550/src/main/kotlin/org/openrs2/cache/sprite/Sprite.kt
  82. 32
      cache-cli/build.gradle.kts
  83. 15
      cache-cli/src/main/kotlin/org/openrs2/cache/cli/CacheCommand.kt
  84. 26
      cache-cli/src/main/kotlin/org/openrs2/cache/cli/OpenNxtUnpackCommand.kt
  85. 27
      cache-cli/src/main/kotlin/org/openrs2/cache/cli/RuneLiteUnpackCommand.kt
  86. 3
      cache/build.gradle.kts
  87. 73
      cache/src/main/kotlin/org/openrs2/cache/Archive.kt
  88. 57
      cache/src/main/kotlin/org/openrs2/cache/Cache.kt
  89. 9
      cache/src/main/kotlin/org/openrs2/cache/ChecksumTable.kt
  90. 6
      cache/src/main/kotlin/org/openrs2/cache/DiskStore.kt
  91. 6
      cache/src/main/kotlin/org/openrs2/cache/FlatFileStore.kt
  92. 3
      cache/src/main/kotlin/org/openrs2/cache/JagArchive.kt
  93. 30
      cache/src/main/kotlin/org/openrs2/cache/Js5Compression.kt
  94. 1
      cache/src/main/kotlin/org/openrs2/cache/Js5CompressionType.kt
  95. 3
      cache/src/main/kotlin/org/openrs2/cache/Js5Index.kt
  96. 7
      cache/src/main/kotlin/org/openrs2/cache/Js5MasterIndex.kt
  97. 8
      cache/src/main/kotlin/org/openrs2/cache/Js5Pack.kt
  98. 1
      cache/src/main/kotlin/org/openrs2/cache/Js5Protocol.kt
  99. 1
      cache/src/main/kotlin/org/openrs2/cache/MutableNamedEntryCollection.kt
  100. 89
      cache/src/main/kotlin/org/openrs2/cache/OpenNxtStore.kt
  101. Some files were not shown because too many files have changed in this diff Show More

@ -15,11 +15,15 @@ indent_style = space
indent_size = 4 indent_size = 4
# see https://github.com/pinterest/ktlint/issues/764 # see https://github.com/pinterest/ktlint/issues/764
# noinspection EditorConfigKeyCorrectness # noinspection EditorConfigKeyCorrectness
disabled_rules = indent, parameter-list-wrapping disabled_rules = argument-list-wrapping, parameter-list-wrapping, wrapping
[*.md] [*.md]
max_line_length = 80 max_line_length = 80
[*.sql]
indent_style = space
indent_size = 4
# @formatter:off # @formatter:off
[*.{json,xml,yaml,yml}] [*.{json,xml,yaml,yml}]
# @formatter:on # @formatter:on

@ -1,13 +1,8 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="AstDeobfuscator" type="JetRunConfigurationType"> <configuration default="false" name="AstDeobfuscator" type="JetRunConfigurationType">
<module name="openrs2.deob-ast.main" />
<option name="VM_PARAMETERS" value="" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="false" />
<option name="ALTERNATIVE_JRE_PATH" />
<option name="PASS_PARENT_ENVS" value="true" />
<option name="MAIN_CLASS_NAME" value="org.openrs2.deob.ast.DeobfuscateAstCommandKt" /> <option name="MAIN_CLASS_NAME" value="org.openrs2.deob.ast.DeobfuscateAstCommandKt" />
<option name="WORKING_DIRECTORY" value="" /> <module name="openrs2.deob-ast.main" />
<shortenClasspath name="NONE" />
<method v="2"> <method v="2">
<option name="Make" enabled="true" /> <option name="Make" enabled="true" />
</method> </method>

@ -1,13 +1,8 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="BytecodeDeobfuscator" type="JetRunConfigurationType"> <configuration default="false" name="BytecodeDeobfuscator" type="JetRunConfigurationType">
<module name="openrs2.deob-bytecode.main" />
<option name="VM_PARAMETERS" value="" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="false" />
<option name="ALTERNATIVE_JRE_PATH" />
<option name="PASS_PARENT_ENVS" value="true" />
<option name="MAIN_CLASS_NAME" value="org.openrs2.deob.bytecode.DeobfuscateBytecodeCommandKt" /> <option name="MAIN_CLASS_NAME" value="org.openrs2.deob.bytecode.DeobfuscateBytecodeCommandKt" />
<option name="WORKING_DIRECTORY" value="" /> <module name="openrs2.deob-bytecode.main" />
<shortenClasspath name="NONE" />
<method v="2"> <method v="2">
<option name="Make" enabled="true" /> <option name="Make" enabled="true" />
</method> </method>

@ -1,13 +1,9 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Decompiler" type="JetRunConfigurationType"> <configuration default="false" name="Decompiler" type="JetRunConfigurationType">
<option name="MAIN_CLASS_NAME" value="org.openrs2.decompiler.DecompileCommandKt" />
<module name="openrs2.decompiler.main" /> <module name="openrs2.decompiler.main" />
<shortenClasspath name="NONE" />
<option name="VM_PARAMETERS" value="-Xmx3G" /> <option name="VM_PARAMETERS" value="-Xmx3G" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="false" />
<option name="ALTERNATIVE_JRE_PATH" />
<option name="PASS_PARENT_ENVS" value="true" />
<option name="MAIN_CLASS_NAME" value="org.openrs2.decompiler.DecompileCommandKt" />
<option name="WORKING_DIRECTORY" value="" />
<method v="2"> <method v="2">
<option name="Make" enabled="true" /> <option name="Make" enabled="true" />
</method> </method>

@ -1,13 +1,9 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Deobfuscator" type="JetRunConfigurationType"> <configuration default="false" name="Deobfuscator" type="JetRunConfigurationType">
<option name="MAIN_CLASS_NAME" value="org.openrs2.deob.DeobfuscateCommandKt" />
<module name="openrs2.deob.main" /> <module name="openrs2.deob.main" />
<shortenClasspath name="NONE" />
<option name="VM_PARAMETERS" value="-Xmx3G" /> <option name="VM_PARAMETERS" value="-Xmx3G" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="false" />
<option name="ALTERNATIVE_JRE_PATH" />
<option name="PASS_PARENT_ENVS" value="true" />
<option name="MAIN_CLASS_NAME" value="org.openrs2.deob.DeobfuscateCommandKt" />
<option name="WORKING_DIRECTORY" value="" />
<method v="2"> <method v="2">
<option name="Make" enabled="true" /> <option name="Make" enabled="true" />
</method> </method>

@ -1,13 +1,9 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="GameServer" type="JetRunConfigurationType"> <configuration default="false" name="GameServer" type="JetRunConfigurationType">
<option name="MAIN_CLASS_NAME" value="org.openrs2.game.GameCommandKt" />
<module name="openrs2.game.main" /> <module name="openrs2.game.main" />
<shortenClasspath name="NONE" />
<option name="VM_PARAMETERS" value="-Dio.netty.leakDetection.level=PARANOID" /> <option name="VM_PARAMETERS" value="-Dio.netty.leakDetection.level=PARANOID" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="false" />
<option name="ALTERNATIVE_JRE_PATH" />
<option name="PASS_PARENT_ENVS" value="true" />
<option name="MAIN_CLASS_NAME" value="org.openrs2.game.GameCommandKt" />
<option name="WORKING_DIRECTORY" value="" />
<method v="2"> <method v="2">
<option name="Make" enabled="true" /> <option name="Make" enabled="true" />
</method> </method>

@ -1,13 +1,8 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="GenerateBuffer" type="JetRunConfigurationType"> <configuration default="false" name="GenerateBuffer" type="JetRunConfigurationType">
<module name="openrs2.buffer-generator.main" />
<option name="VM_PARAMETERS" value="" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="false" />
<option name="ALTERNATIVE_JRE_PATH" />
<option name="PASS_PARENT_ENVS" value="true" />
<option name="MAIN_CLASS_NAME" value="org.openrs2.buffer.generator.GenerateBufferCommandKt" /> <option name="MAIN_CLASS_NAME" value="org.openrs2.buffer.generator.GenerateBufferCommandKt" />
<option name="WORKING_DIRECTORY" value="" /> <module name="openrs2.buffer-generator.main" />
<shortenClasspath name="NONE" />
<method v="2"> <method v="2">
<option name="Make" enabled="true" /> <option name="Make" enabled="true" />
</method> </method>

@ -1,13 +1,8 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Patcher" type="JetRunConfigurationType"> <configuration default="false" name="Patcher" type="JetRunConfigurationType">
<module name="openrs2.patcher.main" />
<option name="VM_PARAMETERS" value="" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="false" />
<option name="ALTERNATIVE_JRE_PATH" />
<option name="PASS_PARENT_ENVS" value="true" />
<option name="MAIN_CLASS_NAME" value="org.openrs2.patcher.PatchCommandKt" /> <option name="MAIN_CLASS_NAME" value="org.openrs2.patcher.PatchCommandKt" />
<option name="WORKING_DIRECTORY" value="" /> <module name="openrs2.patcher.main" />
<shortenClasspath name="NONE" />
<method v="2"> <method v="2">
<option name="Make" enabled="true" /> <option name="Make" enabled="true" />
</method> </method>

@ -1,4 +1,4 @@
Copyright (c) 2019-2022 OpenRS2 Authors Copyright (c) 2019-2023 OpenRS2 Authors
Permission to use, copy, modify, and/or distribute this software for any Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above purpose with or without fee is hereby granted, provided that the above

@ -17,6 +17,7 @@ application {
dependencies { dependencies {
implementation(projects.archive) implementation(projects.archive)
implementation(projects.bufferGenerator) implementation(projects.bufferGenerator)
implementation(projects.cacheCli)
implementation(projects.compressCli) implementation(projects.compressCli)
implementation(projects.crc32) implementation(projects.crc32)
implementation(projects.deob) implementation(projects.deob)
@ -29,6 +30,8 @@ tasks.shadowJar {
archiveFileName.set("openrs2.jar") archiveFileName.set("openrs2.jar")
minimize { minimize {
exclude(dependency("ch.qos.logback:logback-classic"))
exclude(dependency("com.github.jnr:jnr-ffi"))
exclude(dependency("org.flywaydb:flyway-core")) exclude(dependency("org.flywaydb:flyway-core"))
exclude(dependency("org.jetbrains.kotlin:kotlin-reflect")) exclude(dependency("org.jetbrains.kotlin:kotlin-reflect"))
} }

@ -4,6 +4,7 @@ import com.github.ajalt.clikt.core.NoOpCliktCommand
import com.github.ajalt.clikt.core.subcommands import com.github.ajalt.clikt.core.subcommands
import org.openrs2.archive.ArchiveCommand import org.openrs2.archive.ArchiveCommand
import org.openrs2.buffer.generator.GenerateBufferCommand import org.openrs2.buffer.generator.GenerateBufferCommand
import org.openrs2.cache.cli.CacheCommand
import org.openrs2.compress.cli.CompressCommand import org.openrs2.compress.cli.CompressCommand
import org.openrs2.crc32.Crc32Command import org.openrs2.crc32.Crc32Command
import org.openrs2.deob.DeobfuscateCommand import org.openrs2.deob.DeobfuscateCommand
@ -16,6 +17,7 @@ public class Command : NoOpCliktCommand(name = "openrs2") {
init { init {
subcommands( subcommands(
ArchiveCommand(), ArchiveCommand(),
CacheCommand(),
CompressCommand(), CompressCommand(),
Crc32Command(), Crc32Command(),
DeobfuscateCommand(), DeobfuscateCommand(),

@ -9,6 +9,7 @@ application {
} }
dependencies { dependencies {
api(libs.bundles.guice)
api(libs.clikt) api(libs.clikt)
implementation(projects.buffer) implementation(projects.buffer)

@ -5,9 +5,8 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.google.inject.AbstractModule import com.google.inject.AbstractModule
import com.google.inject.Scopes import com.google.inject.Scopes
import com.google.inject.multibindings.Multibinder import com.google.inject.multibindings.Multibinder
import org.openrs2.archive.key.HdosKeyDownloader
import org.openrs2.archive.key.KeyDownloader import org.openrs2.archive.key.KeyDownloader
import org.openrs2.archive.key.OpenOsrsKeyDownloader
import org.openrs2.archive.key.PolarKeyDownloader
import org.openrs2.archive.key.RuneLiteKeyDownloader import org.openrs2.archive.key.RuneLiteKeyDownloader
import org.openrs2.archive.name.NameDownloader import org.openrs2.archive.name.NameDownloader
import org.openrs2.archive.name.RuneStarNameDownloader import org.openrs2.archive.name.RuneStarNameDownloader
@ -41,14 +40,13 @@ public object ArchiveModule : AbstractModule() {
.toProvider(DatabaseProvider::class.java) .toProvider(DatabaseProvider::class.java)
.`in`(Scopes.SINGLETON) .`in`(Scopes.SINGLETON)
Multibinder.newSetBinder(binder(), Module::class.java)
.addBinding().to(JavaTimeModule::class.java)
val keyBinder = Multibinder.newSetBinder(binder(), KeyDownloader::class.java) val keyBinder = Multibinder.newSetBinder(binder(), KeyDownloader::class.java)
keyBinder.addBinding().to(OpenOsrsKeyDownloader::class.java) keyBinder.addBinding().to(HdosKeyDownloader::class.java)
keyBinder.addBinding().to(PolarKeyDownloader::class.java)
keyBinder.addBinding().to(RuneLiteKeyDownloader::class.java) keyBinder.addBinding().to(RuneLiteKeyDownloader::class.java)
val moduleBinder = Multibinder.newSetBinder(binder(), Module::class.java)
moduleBinder.addBinding().to(JavaTimeModule::class.java)
val nameBinder = Multibinder.newSetBinder(binder(), NameDownloader::class.java) val nameBinder = Multibinder.newSetBinder(binder(), NameDownloader::class.java)
nameBinder.addBinding().to(RuneStarNameDownloader::class.java) nameBinder.addBinding().to(RuneStarNameDownloader::class.java)
} }

@ -2,11 +2,14 @@ package org.openrs2.archive.cache
import com.github.ajalt.clikt.core.NoOpCliktCommand import com.github.ajalt.clikt.core.NoOpCliktCommand
import com.github.ajalt.clikt.core.subcommands import com.github.ajalt.clikt.core.subcommands
import org.openrs2.archive.cache.finder.ExtractCommand
public class CacheCommand : NoOpCliktCommand(name = "cache") { public class CacheCommand : NoOpCliktCommand(name = "cache") {
init { init {
subcommands( subcommands(
CrossPollinateCommand(),
DownloadCommand(), DownloadCommand(),
ExtractCommand(),
ImportCommand(), ImportCommand(),
ImportMasterIndexCommand(), ImportMasterIndexCommand(),
ExportCommand(), ExportCommand(),

@ -30,7 +30,7 @@ public class CacheDownloader @Inject constructor(
val group = bootstrapFactory.createEventLoopGroup() val group = bootstrapFactory.createEventLoopGroup()
try { try {
suspendCoroutine<Unit> { continuation -> suspendCoroutine { continuation ->
val bootstrap = bootstrapFactory.createBootstrap(group) val bootstrap = bootstrapFactory.createBootstrap(group)
val hostname: String val hostname: String
@ -43,29 +43,36 @@ public class CacheDownloader @Inject constructor(
OsrsJs5ChannelInitializer( OsrsJs5ChannelInitializer(
OsrsJs5ChannelHandler( OsrsJs5ChannelHandler(
bootstrap, bootstrap,
game.scopeId,
game.id, game.id,
hostname, hostname,
PORT, PORT,
buildMajor, buildMajor,
game.lastMasterIndexId, game.lastMasterIndexId,
continuation, continuation,
importer, importer
) )
) )
} }
"runescape" -> { "runescape" -> {
val buildMinor = game.buildMinor ?: throw Exception("Current minor build not set") val buildMinor = game.buildMinor ?: throw Exception("Current minor build not set")
val tokens = config.params.values.filter { TOKEN_REGEX.matches(it) } val tokens = config.params.values.filter { TOKEN_REGEX.matches(it) }
val token = tokens.singleOrNull() ?: throw Exception("Multiple candidate tokens: $tokens") val token = tokens.singleOrNull() ?: throw Exception("Multiple candidate tokens: $tokens")
hostname = NXT_HOSTNAME hostname = if (environment == "beta") {
NXT_BETA_HOSTNAME
} else {
NXT_LIVE_HOSTNAME
}
val musicStreamClient = MusicStreamClient(client, byteBufBodyHandler, "http://$hostname") val musicStreamClient = MusicStreamClient(client, byteBufBodyHandler, "http://$hostname")
NxtJs5ChannelInitializer( NxtJs5ChannelInitializer(
NxtJs5ChannelHandler( NxtJs5ChannelHandler(
bootstrap, bootstrap,
game.scopeId,
game.id, game.id,
hostname, hostname,
PORT, PORT,
@ -80,6 +87,7 @@ public class CacheDownloader @Inject constructor(
) )
) )
} }
else -> throw UnsupportedOperationException() else -> throw UnsupportedOperationException()
} }
@ -93,7 +101,8 @@ public class CacheDownloader @Inject constructor(
private companion object { private companion object {
private const val CODEBASE = "codebase" private const val CODEBASE = "codebase"
private const val NXT_HOSTNAME = "content.runescape.com" private const val NXT_LIVE_HOSTNAME = "content.runescape.com"
private const val NXT_BETA_HOSTNAME = "content.beta.runescape.com"
private const val PORT = 443 private const val PORT = 443
private val TOKEN_REGEX = Regex("[A-Za-z0-9*-]{32}") private val TOKEN_REGEX = Regex("[A-Za-z0-9*-]{32}")
} }

@ -3,6 +3,7 @@ package org.openrs2.archive.cache
import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.annotation.JsonUnwrapped import com.fasterxml.jackson.annotation.JsonUnwrapped
import io.netty.buffer.ByteBuf
import io.netty.buffer.ByteBufAllocator import io.netty.buffer.ByteBufAllocator
import io.netty.buffer.Unpooled import io.netty.buffer.Unpooled
import org.openrs2.buffer.use import org.openrs2.buffer.use
@ -18,6 +19,8 @@ import org.openrs2.db.Database
import org.postgresql.util.PGobject import org.postgresql.util.PGobject
import java.sql.Connection import java.sql.Connection
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.SortedSet import java.util.SortedSet
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -74,6 +77,51 @@ public class CacheExporter @Inject constructor(
public val diskStoreValid: Boolean = blocks <= DiskStore.MAX_BLOCK public val diskStoreValid: Boolean = blocks <= DiskStore.MAX_BLOCK
} }
public data class Archive(
val resolved: Boolean,
val stats: ArchiveStats?
)
public data class ArchiveStats(
val validGroups: Long,
val groups: Long,
val validKeys: Long,
val keys: Long,
val size: Long,
val blocks: Long
) {
public val allGroupsValid: Boolean = groups == validGroups
public val validGroupsFraction: Double = if (groups == 0L) {
1.0
} else {
validGroups.toDouble() / groups
}
public val allKeysValid: Boolean = keys == validKeys
public val validKeysFraction: Double = if (keys == 0L) {
1.0
} else {
validKeys.toDouble() / keys
}
}
public data class IndexStats(
val validFiles: Long,
val files: Long,
val size: Long,
val blocks: Long
) {
public val allFilesValid: Boolean = files == validFiles
public val validFilesFraction: Double = if (files == 0L) {
1.0
} else {
validFiles.toDouble() / files
}
}
public data class Build(val major: Int, val minor: Int?) : Comparable<Build> { public data class Build(val major: Int, val minor: Int?) : Comparable<Build> {
override fun compareTo(other: Build): Int { override fun compareTo(other: Build): Int {
return compareValuesBy(this, other, Build::major, Build::minor) return compareValuesBy(this, other, Build::major, Build::minor)
@ -109,6 +157,7 @@ public class CacheExporter @Inject constructor(
public data class CacheSummary( public data class CacheSummary(
val id: Int, val id: Int,
val scope: String,
val game: String, val game: String,
val environment: String, val environment: String,
val language: String, val language: String,
@ -124,6 +173,8 @@ public class CacheExporter @Inject constructor(
val sources: List<Source>, val sources: List<Source>,
val updates: List<String>, val updates: List<String>,
val stats: Stats?, val stats: Stats?,
val archives: List<Archive>,
val indexes: List<IndexStats>?,
val masterIndex: Js5MasterIndex?, val masterIndex: Js5MasterIndex?,
val checksumTable: ChecksumTable? val checksumTable: ChecksumTable?
) )
@ -148,6 +199,25 @@ public class CacheExporter @Inject constructor(
val key: XteaKey val key: XteaKey
) )
public suspend fun totalSize(): Long {
return database.execute { connection ->
connection.prepareStatement(
"""
SELECT SUM(size)
FROM cache_stats
""".trimIndent()
).use { stmt ->
stmt.executeQuery().use { rows ->
if (rows.next()) {
rows.getLong(1)
} else {
0
}
}
}
}
}
public suspend fun list(): List<CacheSummary> { public suspend fun list(): List<CacheSummary> {
return database.execute { connection -> return database.execute { connection ->
connection.prepareStatement( connection.prepareStatement(
@ -157,6 +227,7 @@ public class CacheExporter @Inject constructor(
SELECT SELECT
c.id, c.id,
g.name AS game, g.name AS game,
sc.name AS scope,
e.name AS environment, e.name AS environment,
l.iso_code AS language, l.iso_code AS language,
array_remove(array_agg(DISTINCT ROW(s.build_major, s.build_minor)::build ORDER BY ROW(s.build_major, s.build_minor)::build ASC), NULL) builds, array_remove(array_agg(DISTINCT ROW(s.build_major, s.build_minor)::build ORDER BY ROW(s.build_major, s.build_minor)::build ASC), NULL) builds,
@ -174,11 +245,13 @@ public class CacheExporter @Inject constructor(
JOIN sources s ON s.cache_id = c.id JOIN sources s ON s.cache_id = c.id
JOIN game_variants v ON v.id = s.game_id JOIN game_variants v ON v.id = s.game_id
JOIN games g ON g.id = v.game_id JOIN games g ON g.id = v.game_id
JOIN scopes sc ON sc.id = g.scope_id
JOIN environments e ON e.id = v.environment_id JOIN environments e ON e.id = v.environment_id
JOIN languages l ON l.id = v.language_id JOIN languages l ON l.id = v.language_id
LEFT JOIN cache_stats cs ON cs.cache_id = c.id LEFT JOIN cache_stats cs ON cs.scope_id = sc.id AND cs.cache_id = c.id
GROUP BY c.id, g.name, e.name, l.iso_code, cs.valid_indexes, cs.indexes, cs.valid_groups, cs.groups, WHERE NOT c.hidden
cs.valid_keys, cs.keys, cs.size, cs.blocks GROUP BY sc.name, c.id, g.name, e.name, l.iso_code, cs.valid_indexes, cs.indexes, cs.valid_groups,
cs.groups, cs.valid_keys, cs.keys, cs.size, cs.blocks
) t ) t
ORDER BY t.game ASC, t.environment ASC, t.language ASC, t.builds[1] ASC, t.timestamp ASC ORDER BY t.game ASC, t.environment ASC, t.language ASC, t.builds[1] ASC, t.timestamp ASC
""".trimIndent() """.trimIndent()
@ -189,21 +262,24 @@ public class CacheExporter @Inject constructor(
while (rows.next()) { while (rows.next()) {
val id = rows.getInt(1) val id = rows.getInt(1)
val game = rows.getString(2) val game = rows.getString(2)
val environment = rows.getString(3) val scope = rows.getString(3)
val language = rows.getString(4) val environment = rows.getString(4)
val builds = rows.getArray(5).array as Array<*> val language = rows.getString(5)
val timestamp = rows.getTimestamp(6)?.toInstant() val builds = rows.getArray(6).array as Array<*>
@Suppress("UNCHECKED_CAST") val sources = rows.getArray(7).array as Array<String> val timestamp = rows.getTimestamp(7)?.toInstant()
val validIndexes = rows.getLong(8) @Suppress("UNCHECKED_CAST")
val sources = rows.getArray(8).array as Array<String>
val validIndexes = rows.getLong(9)
val stats = if (!rows.wasNull()) { val stats = if (!rows.wasNull()) {
val indexes = rows.getLong(9) val indexes = rows.getLong(10)
val validGroups = rows.getLong(10) val validGroups = rows.getLong(11)
val groups = rows.getLong(11) val groups = rows.getLong(12)
val validKeys = rows.getLong(12) val validKeys = rows.getLong(13)
val keys = rows.getLong(13) val keys = rows.getLong(14)
val size = rows.getLong(14) val size = rows.getLong(15)
val blocks = rows.getLong(15) val blocks = rows.getLong(16)
Stats(validIndexes, indexes, validGroups, groups, validKeys, keys, size, blocks) Stats(validIndexes, indexes, validGroups, groups, validKeys, keys, size, blocks)
} else { } else {
null null
@ -211,6 +287,7 @@ public class CacheExporter @Inject constructor(
caches += CacheSummary( caches += CacheSummary(
id, id,
scope,
game, game,
environment, environment,
language, language,
@ -227,7 +304,7 @@ public class CacheExporter @Inject constructor(
} }
} }
public suspend fun get(id: Int): Cache? { public suspend fun get(scope: String, id: Int): Cache? {
return database.execute { connection -> return database.execute { connection ->
val masterIndex: Js5MasterIndex? val masterIndex: Js5MasterIndex?
val checksumTable: ChecksumTable? val checksumTable: ChecksumTable?
@ -248,15 +325,17 @@ public class CacheExporter @Inject constructor(
cs.size, cs.size,
cs.blocks cs.blocks
FROM caches c FROM caches c
CROSS JOIN scopes s
LEFT JOIN master_indexes m ON m.id = c.id LEFT JOIN master_indexes m ON m.id = c.id
LEFT JOIN containers mc ON mc.id = m.container_id LEFT JOIN containers mc ON mc.id = m.container_id
LEFT JOIN crc_tables t ON t.id = c.id LEFT JOIN crc_tables t ON t.id = c.id
LEFT JOIN blobs b ON b.id = t.blob_id LEFT JOIN blobs b ON b.id = t.blob_id
LEFT JOIN cache_stats cs ON cs.cache_id = c.id LEFT JOIN cache_stats cs ON cs.scope_id = s.id AND cs.cache_id = c.id
WHERE c.id = ? WHERE s.name = ? AND c.id = ?
""".trimIndent() """.trimIndent()
).use { stmt -> ).use { stmt ->
stmt.setInt(1, id) stmt.setString(1, scope)
stmt.setInt(2, id)
stmt.executeQuery().use { rows -> stmt.executeQuery().use { rows ->
if (!rows.next()) { if (!rows.next()) {
@ -308,13 +387,15 @@ public class CacheExporter @Inject constructor(
FROM sources s FROM sources s
JOIN game_variants v ON v.id = s.game_id JOIN game_variants v ON v.id = s.game_id
JOIN games g ON g.id = v.game_id JOIN games g ON g.id = v.game_id
JOIN scopes sc ON sc.id = g.scope_id
JOIN environments e ON e.id = v.environment_id JOIN environments e ON e.id = v.environment_id
JOIN languages l ON l.id = v.language_id JOIN languages l ON l.id = v.language_id
WHERE s.cache_id = ? WHERE sc.name = ? AND s.cache_id = ?
ORDER BY s.name ASC ORDER BY s.name ASC
""".trimIndent() """.trimIndent()
).use { stmt -> ).use { stmt ->
stmt.setInt(1, id) stmt.setString(1, scope)
stmt.setInt(2, id)
stmt.executeQuery().use { rows -> stmt.executeQuery().use { rows ->
while (rows.next()) { while (rows.next()) {
@ -366,11 +447,201 @@ public class CacheExporter @Inject constructor(
} }
} }
Cache(id, sources, updates, stats, masterIndex, checksumTable) val archives = mutableListOf<Archive>()
connection.prepareStatement(
"""
SELECT a.archive_id, c.id IS NOT NULL, s.valid_groups, s.groups, s.valid_keys, s.keys, s.size, s.blocks
FROM master_index_archives a
LEFT JOIN resolve_index((SELECT id FROM scopes WHERE name = ?), a.archive_id, a.crc32, a.version) c ON TRUE
LEFT JOIN index_stats s ON s.container_id = c.id
WHERE a.master_index_id = ?
UNION ALL
SELECT a.archive_id, b.id IS NOT NULL, NULL, NULL, NULL, NULL, length(b.data), group_blocks(a.archive_id, length(b.data))
FROM crc_table_archives a
LEFT JOIN resolve_archive(a.archive_id, a.crc32) b ON TRUE
WHERE a.crc_table_id = ?
ORDER BY archive_id ASC
""".trimIndent()
).use { stmt ->
stmt.setString(1, scope)
stmt.setInt(2, id)
stmt.setInt(3, id)
stmt.executeQuery().use { rows ->
while (rows.next()) {
val resolved = rows.getBoolean(2)
val size = rows.getLong(7)
val archiveStats = if (!rows.wasNull()) {
val validGroups = rows.getLong(3)
val groups = rows.getLong(4)
val validKeys = rows.getLong(5)
val keys = rows.getLong(6)
val blocks = rows.getLong(8)
ArchiveStats(validGroups, groups, validKeys, keys, size, blocks)
} else {
null
}
archives += Archive(resolved, archiveStats)
}
}
}
val indexes = if (checksumTable != null && archives[5].resolved) {
connection.prepareStatement(
"""
SELECT s.valid_files, s.files, s.size, s.blocks
FROM crc_table_archives a
JOIN resolve_archive(a.archive_id, a.crc32) b ON TRUE
JOIN version_list_stats s ON s.blob_id = b.id
WHERE a.crc_table_id = ? AND a.archive_id = 5
ORDER BY s.index_id ASC
""".trimIndent()
).use { stmt ->
stmt.setInt(1, id)
stmt.executeQuery().use { rows ->
val indexes = mutableListOf<IndexStats>()
while (rows.next()) {
val validFiles = rows.getLong(1)
val files = rows.getLong(2)
val size = rows.getLong(3)
val blocks = rows.getLong(4)
indexes += IndexStats(validFiles, files, size, blocks)
}
indexes
}
}
} else {
null
}
Cache(id, sources, updates, stats, archives, indexes, masterIndex, checksumTable)
}
}
public suspend fun getFileName(scope: String, id: Int): String? {
return database.execute { connection ->
// TODO(gpe): what if a cache is from multiple games?
connection.prepareStatement(
"""
SELECT
g.name AS game,
e.name AS environment,
l.iso_code AS language,
array_remove(array_agg(DISTINCT ROW(s.build_major, s.build_minor)::build ORDER BY ROW(s.build_major, s.build_minor)::build ASC), NULL) builds,
MIN(s.timestamp) AS timestamp
FROM sources s
JOIN game_variants v ON v.id = s.game_id
JOIN games g ON g.id = v.game_id
JOIN scopes sc ON sc.id = g.scope_id
JOIN environments e ON e.id = v.environment_id
JOIN languages l ON l.id = v.language_id
WHERE sc.name = ? AND s.cache_id = ?
GROUP BY g.name, e.name, l.iso_code
LIMIT 1
""".trimIndent()
).use { stmt ->
stmt.setString(1, scope)
stmt.setInt(2, id)
stmt.executeQuery().use { rows ->
if (!rows.next()) {
return@execute null
}
val game = rows.getString(1)
val environment = rows.getString(2)
val language = rows.getString(3)
val name = StringBuilder("$game-$environment-$language")
val builds = rows.getArray(4).array as Array<*>
for (build in builds.mapNotNull { o -> Build.fromPgObject(o as PGobject) }.toSortedSet()) {
name.append("-b")
name.append(build)
}
val timestamp = rows.getTimestamp(5)
if (!rows.wasNull()) {
name.append('-')
name.append(
timestamp.toInstant()
.atOffset(ZoneOffset.UTC)
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss"))
)
}
name.append("-openrs2#")
name.append(id)
name.toString()
}
}
}
}
public suspend fun exportGroup(scope: String, id: Int, archive: Int, group: Int): ByteBuf? {
return database.execute { connection ->
if (archive == Store.ARCHIVESET && group == Store.ARCHIVESET) {
connection.prepareStatement(
"""
SELECT c.data
FROM master_indexes m
JOIN containers c ON c.id = m.container_id
WHERE m.id = ?
""".trimIndent()
).use { stmt ->
stmt.setInt(1, id)
stmt.executeQuery().use { rows ->
if (rows.next()) {
val data = rows.getBytes(1)
return@execute Unpooled.wrappedBuffer(data)
}
}
} }
} }
public fun export(id: Int, storeFactory: (Boolean) -> Store) { connection.prepareStatement(
"""
SELECT g.data
FROM resolved_groups g
JOIN scopes s ON s.id = g.scope_id
WHERE s.name = ? AND g.master_index_id = ? AND g.archive_id = ? AND g.group_id = ?
UNION ALL
SELECT f.data
FROM resolved_files f
WHERE f.crc_table_id = ? AND f.index_id = ? AND f.file_id = ?
""".trimIndent()
).use { stmt ->
stmt.setString(1, scope)
stmt.setInt(2, id)
stmt.setInt(3, archive)
stmt.setInt(4, group)
stmt.setInt(5, id)
stmt.setInt(6, archive)
stmt.setInt(7, group)
stmt.executeQuery().use { rows ->
if (!rows.next()) {
return@execute null
}
val data = rows.getBytes(1)
return@execute Unpooled.wrappedBuffer(data)
}
}
}
}
public fun export(scope: String, id: Int, storeFactory: (Boolean) -> Store) {
database.executeOnce { connection -> database.executeOnce { connection ->
val legacy = connection.prepareStatement( val legacy = connection.prepareStatement(
""" """
@ -390,22 +661,24 @@ public class CacheExporter @Inject constructor(
if (legacy) { if (legacy) {
exportLegacy(connection, id, store) exportLegacy(connection, id, store)
} else { } else {
export(connection, id, store) export(connection, scope, id, store)
} }
} }
} }
} }
private fun export(connection: Connection, id: Int, store: Store) { private fun export(connection: Connection, scope: String, id: Int, store: Store) {
connection.prepareStatement( connection.prepareStatement(
""" """
SELECT archive_id, group_id, data, version SELECT g.archive_id, g.group_id, g.data, g.version
FROM resolved_groups FROM resolved_groups g
WHERE master_index_id = ? JOIN scopes s ON s.id = g.scope_id
WHERE s.name = ? AND g.master_index_id = ?
""".trimIndent() """.trimIndent()
).use { stmt -> ).use { stmt ->
stmt.fetchSize = BATCH_SIZE stmt.fetchSize = BATCH_SIZE
stmt.setInt(1, id) stmt.setString(1, scope)
stmt.setInt(2, id)
stmt.executeQuery().use { rows -> stmt.executeQuery().use { rows ->
alloc.buffer(2, 2).use { versionBuf -> alloc.buffer(2, 2).use { versionBuf ->
@ -473,18 +746,20 @@ public class CacheExporter @Inject constructor(
} }
} }
public suspend fun exportKeys(id: Int): List<Key> { public suspend fun exportKeys(scope: String, id: Int): List<Key> {
return database.execute { connection -> return database.execute { connection ->
connection.prepareStatement( connection.prepareStatement(
""" """
SELECT g.archive_id, g.group_id, g.name_hash, n.name, (k.key).k0, (k.key).k1, (k.key).k2, (k.key).k3 SELECT g.archive_id, g.group_id, g.name_hash, n.name, (k.key).k0, (k.key).k1, (k.key).k2, (k.key).k3
FROM resolved_groups g FROM resolved_groups g
JOIN scopes s ON s.id = g.scope_id
JOIN keys k ON k.id = g.key_id JOIN keys k ON k.id = g.key_id
LEFT JOIN names n ON n.hash = g.name_hash AND n.name ~ '^l(?:[0-9]|[1-9][0-9])_(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$' LEFT JOIN names n ON n.hash = g.name_hash AND n.name ~ '^l(?:[0-9]|[1-9][0-9])_(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$'
WHERE g.master_index_id = ? WHERE s.name = ? AND g.master_index_id = ?
""".trimIndent() """.trimIndent()
).use { stmt -> ).use { stmt ->
stmt.setInt(1, id) stmt.setString(1, scope)
stmt.setInt(2, id)
stmt.executeQuery().use { rows -> stmt.executeQuery().use { rows ->
val keys = mutableListOf<Key>() val keys = mutableListOf<Key>()

@ -105,9 +105,10 @@ public class CacheImporter @Inject constructor(
public val version: Int public val version: Int
) : Blob(buf) ) : Blob(buf)
private enum class SourceType { internal enum class SourceType {
DISK, DISK,
JS5REMOTE JS5REMOTE,
CROSS_POLLINATION
} }
public data class MasterIndexResult( public data class MasterIndexResult(
@ -116,9 +117,14 @@ public class CacheImporter @Inject constructor(
val indexes: List<ByteBuf?> val indexes: List<ByteBuf?>
) )
private data class Game(
val id: Int,
val scopeId: Int
)
public suspend fun import( public suspend fun import(
store: Store, store: Store,
game: String, gameName: String,
environment: String, environment: String,
language: String, language: String,
buildMajor: Int?, buildMajor: Int?,
@ -131,12 +137,12 @@ public class CacheImporter @Inject constructor(
database.execute { connection -> database.execute { connection ->
prepare(connection) prepare(connection)
val gameId = getGameId(connection, game, environment, language) val game = getGame(connection, gameName, environment, language)
if (store is DiskStore && store.legacy) { if (store is DiskStore && store.legacy) {
importLegacy(connection, store, gameId, buildMajor, buildMinor, timestamp, name, description, url) importLegacy(connection, store, game.id, buildMajor, buildMinor, timestamp, name, description, url)
} else { } else {
importJs5(connection, store, gameId, buildMajor, buildMinor, timestamp, name, description, url) importJs5(connection, store, game, buildMajor, buildMinor, timestamp, name, description, url)
} }
} }
} }
@ -144,7 +150,7 @@ public class CacheImporter @Inject constructor(
private fun importJs5( private fun importJs5(
connection: Connection, connection: Connection,
store: Store, store: Store,
gameId: Int, game: Game,
buildMajor: Int?, buildMajor: Int?,
buildMinor: Int?, buildMinor: Int?,
timestamp: Instant?, timestamp: Instant?,
@ -169,7 +175,7 @@ public class CacheImporter @Inject constructor(
connection, connection,
SourceType.DISK, SourceType.DISK,
masterIndexId, masterIndexId,
gameId, game.id,
buildMajor, buildMajor,
buildMinor, buildMinor,
timestamp, timestamp,
@ -194,7 +200,7 @@ public class CacheImporter @Inject constructor(
} }
for (index in indexGroups) { for (index in indexGroups) {
addIndex(connection, sourceId, index) addIndex(connection, game.scopeId, sourceId, index)
} }
} finally { } finally {
indexGroups.forEach(Index::release) indexGroups.forEach(Index::release)
@ -215,7 +221,7 @@ public class CacheImporter @Inject constructor(
groups += group groups += group
if (groups.size >= BATCH_SIZE) { if (groups.size >= BATCH_SIZE) {
addGroups(connection, sourceId, groups) addGroups(connection, game.scopeId, sourceId, groups)
groups.forEach(Group::release) groups.forEach(Group::release)
groups.clear() groups.clear()
@ -224,7 +230,7 @@ public class CacheImporter @Inject constructor(
} }
if (groups.isNotEmpty()) { if (groups.isNotEmpty()) {
addGroups(connection, sourceId, groups) addGroups(connection, game.scopeId, sourceId, groups)
} }
} finally { } finally {
groups.forEach(Group::release) groups.forEach(Group::release)
@ -234,7 +240,7 @@ public class CacheImporter @Inject constructor(
public suspend fun importMasterIndex( public suspend fun importMasterIndex(
buf: ByteBuf, buf: ByteBuf,
format: MasterIndexFormat, format: MasterIndexFormat,
game: String, gameName: String,
environment: String, environment: String,
language: String, language: String,
buildMajor: Int?, buildMajor: Int?,
@ -254,14 +260,14 @@ public class CacheImporter @Inject constructor(
database.execute { connection -> database.execute { connection ->
prepare(connection) prepare(connection)
val gameId = getGameId(connection, game, environment, language) val game = getGame(connection, gameName, environment, language)
val masterIndexId = addMasterIndex(connection, masterIndex) val masterIndexId = addMasterIndex(connection, masterIndex)
addSource( addSource(
connection, connection,
SourceType.DISK, SourceType.DISK,
masterIndexId, masterIndexId,
gameId, game.id,
buildMajor, buildMajor,
buildMinor, buildMinor,
timestamp, timestamp,
@ -278,6 +284,7 @@ public class CacheImporter @Inject constructor(
buf: ByteBuf, buf: ByteBuf,
uncompressed: ByteBuf, uncompressed: ByteBuf,
gameId: Int, gameId: Int,
scopeId: Int,
buildMajor: Int, buildMajor: Int,
buildMinor: Int?, buildMinor: Int?,
lastId: Int?, lastId: Int?,
@ -332,13 +339,14 @@ public class CacheImporter @Inject constructor(
FROM master_index_archives a FROM master_index_archives a
LEFT JOIN master_index_archives a2 ON a2.master_index_id = ? AND a2.archive_id = a.archive_id AND LEFT JOIN master_index_archives a2 ON a2.master_index_id = ? AND a2.archive_id = a.archive_id AND
a2.crc32 = a.crc32 AND a2.version = a.version a2.crc32 = a.crc32 AND a2.version = a.version
LEFT JOIN resolve_index(a2.archive_id, a2.crc32, a2.version) c ON TRUE LEFT JOIN resolve_index(?, a2.archive_id, a2.crc32, a2.version) c ON TRUE
WHERE a.master_index_id = ? WHERE a.master_index_id = ?
ORDER BY a.archive_id ASC ORDER BY a.archive_id ASC
""".trimIndent() """.trimIndent()
).use { stmt -> ).use { stmt ->
stmt.setObject(1, lastId, Types.INTEGER) stmt.setObject(1, lastId, Types.INTEGER)
stmt.setInt(2, masterIndexId) stmt.setInt(2, scopeId)
stmt.setInt(3, masterIndexId)
stmt.executeQuery().use { rows -> stmt.executeQuery().use { rows ->
val indexes = mutableListOf<ByteBuf?>() val indexes = mutableListOf<ByteBuf?>()
@ -363,6 +371,7 @@ public class CacheImporter @Inject constructor(
} }
public suspend fun importIndexAndGetMissingGroups( public suspend fun importIndexAndGetMissingGroups(
scopeId: Int,
sourceId: Int, sourceId: Int,
archive: Int, archive: Int,
index: Js5Index, index: Js5Index,
@ -372,7 +381,7 @@ public class CacheImporter @Inject constructor(
): List<Int> { ): List<Int> {
return database.execute { connection -> return database.execute { connection ->
prepare(connection) prepare(connection)
val id = addIndex(connection, sourceId, Index(archive, index, buf, uncompressed)) val id = addIndex(connection, scopeId, sourceId, Index(archive, index, buf, uncompressed))
/* /*
* In order to defend against (crc32, version) collisions, we only * In order to defend against (crc32, version) collisions, we only
@ -390,17 +399,18 @@ public class CacheImporter @Inject constructor(
SELECT ig.group_id SELECT ig.group_id
FROM index_groups ig FROM index_groups ig
LEFT JOIN resolved_indexes i ON i.master_index_id = ? AND LEFT JOIN resolved_indexes i ON i.master_index_id = ? AND
i.archive_id = ? i.archive_id = ? AND i.scope_id = ?
LEFT JOIN index_groups ig2 ON ig2.container_id = i.container_id AND ig2.group_id = ig.group_id AND LEFT JOIN index_groups ig2 ON ig2.container_id = i.container_id AND ig2.group_id = ig.group_id AND
ig2.crc32 = ig.crc32 AND ig2.version = ig.version ig2.crc32 = ig.crc32 AND ig2.version = ig.version
LEFT JOIN resolve_group(i.archive_id, ig2.group_id, ig2.crc32, ig2.version) c ON TRUE LEFT JOIN resolve_group(i.scope_id, i.archive_id, ig2.group_id, ig2.crc32, ig2.version) c ON TRUE
WHERE ig.container_id = ? AND c.id IS NULL WHERE ig.container_id = ? AND c.id IS NULL
ORDER BY ig.group_id ASC ORDER BY ig.group_id ASC
""".trimIndent() """.trimIndent()
).use { stmt -> ).use { stmt ->
stmt.setObject(1, lastMasterIndexId, Types.INTEGER) stmt.setObject(1, lastMasterIndexId, Types.INTEGER)
stmt.setInt(2, archive) stmt.setInt(2, archive)
stmt.setLong(3, id) stmt.setInt(3, scopeId)
stmt.setLong(4, id)
stmt.executeQuery().use { rows -> stmt.executeQuery().use { rows ->
val groups = mutableListOf<Int>() val groups = mutableListOf<Int>()
@ -415,14 +425,14 @@ public class CacheImporter @Inject constructor(
} }
} }
public suspend fun importGroups(sourceId: Int, groups: List<Group>) { public suspend fun importGroups(scopeId: Int, sourceId: Int, groups: List<Group>) {
if (groups.isEmpty()) { if (groups.isEmpty()) {
return return
} }
database.execute { connection -> database.execute { connection ->
prepare(connection) prepare(connection)
addGroups(connection, sourceId, groups) addGroups(connection, scopeId, sourceId, groups)
} }
} }
@ -531,11 +541,11 @@ public class CacheImporter @Inject constructor(
return masterIndexId return masterIndexId
} }
private fun addSource( internal fun addSource(
connection: Connection, connection: Connection,
type: SourceType, type: SourceType,
cacheId: Int, cacheId: Int?,
gameId: Int, gameId: Int?,
buildMajor: Int?, buildMajor: Int?,
buildMinor: Int?, buildMinor: Int?,
timestamp: Instant?, timestamp: Instant?,
@ -543,7 +553,23 @@ public class CacheImporter @Inject constructor(
description: String?, description: String?,
url: String? url: String?
): Int { ): Int {
if (type == SourceType.JS5REMOTE && buildMajor != null) { if (type == SourceType.CROSS_POLLINATION) {
connection.prepareStatement(
"""
SELECT id
FROM sources
WHERE type = 'cross_pollination'
""".trimIndent()
).use { stmt ->
stmt.executeQuery().use { rows ->
if (rows.next()) {
return rows.getInt(1)
}
}
}
}
if (type == SourceType.JS5REMOTE && cacheId != null && gameId != null && buildMajor != null) {
connection.prepareStatement( connection.prepareStatement(
""" """
SELECT id SELECT id
@ -572,8 +598,8 @@ public class CacheImporter @Inject constructor(
""".trimIndent() """.trimIndent()
).use { stmt -> ).use { stmt ->
stmt.setString(1, type.toString().lowercase()) stmt.setString(1, type.toString().lowercase())
stmt.setInt(2, cacheId) stmt.setObject(2, cacheId, Types.INTEGER)
stmt.setInt(3, gameId) stmt.setObject(3, gameId, Types.INTEGER)
stmt.setObject(4, buildMajor, Types.INTEGER) stmt.setObject(4, buildMajor, Types.INTEGER)
stmt.setObject(5, buildMinor, Types.INTEGER) stmt.setObject(5, buildMinor, Types.INTEGER)
@ -627,22 +653,23 @@ public class CacheImporter @Inject constructor(
} }
} }
private fun addGroups(connection: Connection, sourceId: Int, groups: List<Group>): List<Long> { internal fun addGroups(connection: Connection, scopeId: Int, sourceId: Int, groups: List<Group>): List<Long> {
val containerIds = addContainers(connection, groups) val containerIds = addContainers(connection, groups)
connection.prepareStatement( connection.prepareStatement(
""" """
INSERT INTO groups (archive_id, group_id, version, version_truncated, container_id) INSERT INTO groups (scope_id, archive_id, group_id, version, version_truncated, container_id)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
""".trimIndent() """.trimIndent()
).use { stmt -> ).use { stmt ->
for ((i, group) in groups.withIndex()) { for ((i, group) in groups.withIndex()) {
stmt.setInt(1, group.archive) stmt.setInt(1, scopeId)
stmt.setInt(2, group.group) stmt.setInt(2, group.archive)
stmt.setInt(3, group.version) stmt.setInt(3, group.group)
stmt.setBoolean(4, group.versionTruncated) stmt.setInt(4, group.version)
stmt.setLong(5, containerIds[i]) stmt.setBoolean(5, group.versionTruncated)
stmt.setLong(6, containerIds[i])
stmt.addBatch() stmt.addBatch()
} }
@ -672,8 +699,8 @@ public class CacheImporter @Inject constructor(
return containerIds return containerIds
} }
private fun addGroup(connection: Connection, sourceId: Int, group: Group): Long { private fun addGroup(connection: Connection, scopeId: Int, sourceId: Int, group: Group): Long {
return addGroups(connection, sourceId, listOf(group)).single() return addGroups(connection, scopeId, sourceId, listOf(group)).single()
} }
private fun readIndex(store: Store, archive: Int): Index { private fun readIndex(store: Store, archive: Int): Index {
@ -684,8 +711,8 @@ public class CacheImporter @Inject constructor(
} }
} }
private fun addIndex(connection: Connection, sourceId: Int, index: Index): Long { private fun addIndex(connection: Connection, scopeId: Int, sourceId: Int, index: Index): Long {
val containerId = addGroup(connection, sourceId, index) val containerId = addGroup(connection, scopeId, sourceId, index)
val savepoint = connection.setSavepoint() val savepoint = connection.setSavepoint()
connection.prepareStatement( connection.prepareStatement(
@ -785,7 +812,7 @@ public class CacheImporter @Inject constructor(
return containerId return containerId
} }
private fun prepare(connection: Connection) { internal fun prepare(connection: Connection) {
connection.prepareStatement( connection.prepareStatement(
""" """
LOCK TABLE containers IN EXCLUSIVE MODE LOCK TABLE containers IN EXCLUSIVE MODE
@ -794,6 +821,17 @@ public class CacheImporter @Inject constructor(
stmt.execute() stmt.execute()
} }
connection.prepareStatement(
"""
CREATE TEMPORARY TABLE tmp_container_hashes (
index INTEGER NOT NULL,
whirlpool BYTEA NOT NULL
) ON COMMIT DROP
""".trimIndent()
).use { stmt ->
stmt.execute()
}
connection.prepareStatement( connection.prepareStatement(
""" """
CREATE TEMPORARY TABLE tmp_containers ( CREATE TEMPORARY TABLE tmp_containers (
@ -832,12 +870,59 @@ public class CacheImporter @Inject constructor(
private fun addContainers(connection: Connection, containers: List<Container>): List<Long> { private fun addContainers(connection: Connection, containers: List<Container>): List<Long> {
connection.prepareStatement( connection.prepareStatement(
""" """
TRUNCATE TABLE tmp_containers TRUNCATE TABLE tmp_containers, tmp_container_hashes
""".trimIndent() """.trimIndent()
).use { stmt -> ).use { stmt ->
stmt.execute() stmt.execute()
} }
connection.prepareStatement(
"""
INSERT INTO tmp_container_hashes (index, whirlpool)
VALUES (?, ?)
""".trimIndent()
).use { stmt ->
for ((i, container) in containers.withIndex()) {
stmt.setInt(1, i)
stmt.setBytes(2, container.whirlpool)
stmt.addBatch()
}
stmt.executeBatch()
}
val ids = mutableListOf<Long?>()
var count = 0
connection.prepareStatement(
"""
SELECT c.id
FROM tmp_container_hashes t
LEFT JOIN containers c ON c.whirlpool = t.whirlpool
ORDER BY t.index ASC
""".trimIndent()
).use { stmt ->
stmt.executeQuery().use { rows ->
while (rows.next()) {
val id = rows.getLong(1)
if (rows.wasNull()) {
ids += null
} else {
ids += id
count++
}
}
}
}
check(ids.size == containers.size)
if (count == containers.size) {
@Suppress("UNCHECKED_CAST")
return ids as List<Long>
}
connection.prepareStatement( connection.prepareStatement(
""" """
INSERT INTO tmp_containers (index, crc32, whirlpool, data, uncompressed_length, uncompressed_crc32, encrypted, empty_loc) INSERT INTO tmp_containers (index, crc32, whirlpool, data, uncompressed_length, uncompressed_crc32, encrypted, empty_loc)
@ -845,6 +930,10 @@ public class CacheImporter @Inject constructor(
""".trimIndent() """.trimIndent()
).use { stmt -> ).use { stmt ->
for ((i, container) in containers.withIndex()) { for ((i, container) in containers.withIndex()) {
if (ids[i] != null) {
continue
}
stmt.setInt(1, i) stmt.setInt(1, i)
stmt.setInt(2, container.crc32) stmt.setInt(2, container.crc32)
stmt.setBytes(3, container.whirlpool) stmt.setBytes(3, container.whirlpool)
@ -878,11 +967,9 @@ public class CacheImporter @Inject constructor(
stmt.execute() stmt.execute()
} }
val ids = mutableListOf<Long>()
connection.prepareStatement( connection.prepareStatement(
""" """
SELECT c.id SELECT t.index, c.id
FROM tmp_containers t FROM tmp_containers t
JOIN containers c ON c.whirlpool = t.whirlpool JOIN containers c ON c.whirlpool = t.whirlpool
ORDER BY t.index ASC ORDER BY t.index ASC
@ -890,13 +977,19 @@ public class CacheImporter @Inject constructor(
).use { stmt -> ).use { stmt ->
stmt.executeQuery().use { rows -> stmt.executeQuery().use { rows ->
while (rows.next()) { while (rows.next()) {
ids += rows.getLong(1) val index = rows.getInt(1)
val id = rows.getLong(2)
ids[index] = id
count++
} }
} }
} }
check(ids.size == containers.size) check(count == containers.size)
return ids
@Suppress("UNCHECKED_CAST")
return ids as List<Long>
} }
private fun addBlob(connection: Connection, blob: Blob): Long { private fun addBlob(connection: Connection, blob: Blob): Long {
@ -964,10 +1057,10 @@ public class CacheImporter @Inject constructor(
return ids return ids
} }
private fun getGameId(connection: Connection, name: String, environment: String, language: String): Int { private fun getGame(connection: Connection, name: String, environment: String, language: String): Game {
connection.prepareStatement( connection.prepareStatement(
""" """
SELECT v.id SELECT v.id, g.scope_id
FROM game_variants v FROM game_variants v
JOIN games g ON g.id = v.game_id JOIN games g ON g.id = v.game_id
JOIN environments e ON e.id = v.environment_id JOIN environments e ON e.id = v.environment_id
@ -984,7 +1077,10 @@ public class CacheImporter @Inject constructor(
throw Exception("Game not found") throw Exception("Game not found")
} }
return rows.getInt(1) val id = rows.getInt(1)
val scopeId = rows.getInt(2)
return Game(id, scopeId)
} }
} }
} }
@ -1043,9 +1139,14 @@ public class CacheImporter @Inject constructor(
// import archives and version list // import archives and version list
for (id in store.list(0)) { for (id in store.list(0)) {
try {
readArchive(store, id).use { archive -> readArchive(store, id).use { archive ->
addArchive(connection, sourceId, archive) addArchive(connection, sourceId, archive)
} }
} catch (ex: StoreCorruptException) {
// see the comment in ChecksumTable::create
logger.warn(ex) { "Skipping corrupt archive ($id)" }
}
} }
// import files // import files
@ -1280,7 +1381,7 @@ public class CacheImporter @Inject constructor(
} }
} }
private fun addFiles(connection: Connection, sourceId: Int, files: List<File>) { internal fun addFiles(connection: Connection, sourceId: Int, files: List<File>) {
val blobIds = addBlobs(connection, files) val blobIds = addBlobs(connection, files)
connection.prepareStatement( connection.prepareStatement(
@ -1325,6 +1426,23 @@ public class CacheImporter @Inject constructor(
public suspend fun refreshViews() { public suspend fun refreshViews() {
database.execute { connection -> database.execute { connection ->
connection.prepareStatement(
"""
SELECT pg_try_advisory_lock(0)
""".trimIndent()
).use { stmt ->
stmt.executeQuery().use { rows ->
if (!rows.next()) {
throw IllegalStateException()
}
val locked = rows.getBoolean(1)
if (!locked) {
return@execute
}
}
}
connection.prepareStatement( connection.prepareStatement(
""" """
REFRESH MATERIALIZED VIEW CONCURRENTLY index_stats REFRESH MATERIALIZED VIEW CONCURRENTLY index_stats

@ -0,0 +1,16 @@
package org.openrs2.archive.cache
import com.github.ajalt.clikt.core.CliktCommand
import com.google.inject.Guice
import kotlinx.coroutines.runBlocking
import org.openrs2.archive.ArchiveModule
import org.openrs2.inject.CloseableInjector
public class CrossPollinateCommand : CliktCommand(name = "cross-pollinate") {
override fun run(): Unit = runBlocking {
CloseableInjector(Guice.createInjector(ArchiveModule)).use { injector ->
val crossPollinator = injector.getInstance(CrossPollinator::class.java)
crossPollinator.crossPollinate()
}
}
}

@ -0,0 +1,223 @@
package org.openrs2.archive.cache
import io.netty.buffer.ByteBuf
import io.netty.buffer.ByteBufAllocator
import io.netty.buffer.ByteBufInputStream
import io.netty.buffer.Unpooled
import org.openrs2.buffer.crc32
import org.openrs2.buffer.use
import org.openrs2.cache.Js5Compression
import org.openrs2.cache.Js5CompressionType
import org.openrs2.db.Database
import java.sql.Connection
import java.util.zip.GZIPInputStream
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
public class CrossPollinator @Inject constructor(
private val database: Database,
private val alloc: ByteBufAllocator,
private val importer: CacheImporter
) {
public suspend fun crossPollinate() {
database.execute { connection ->
for ((index, archive) in OLD_TO_NEW_ENGINE) {
crossPollinate(connection, index, archive);
}
}
}
private fun crossPollinate(connection: Connection, index: Int, archive: Int) {
val scopeId: Int
connection.prepareStatement(
"""
SELECT id
FROM scopes
WHERE name = 'runescape'
""".trimIndent()
).use { stmt ->
stmt.executeQuery().use { rows ->
check(rows.next())
scopeId = rows.getInt(1)
}
}
val groups = mutableListOf<CacheImporter.Group>()
val files = mutableListOf<CacheImporter.File>()
try {
connection.prepareStatement(
"""
SELECT
new.group_id AS id,
old.version AS old_version,
old.crc32 AS old_crc32,
b.data AS old_data,
new.version AS new_version,
new.crc32 AS new_crc32,
c.data AS new_data
FROM (
SELECT DISTINCT vf.index_id, vf.file_id, vf.version, vf.crc32
FROM version_list_files vf
WHERE vf.blob_id IN (
SELECT v.blob_id
FROM version_lists v
JOIN resolved_archives a ON a.blob_id = v.blob_id AND a.archive_id = 5
) AND vf.index_id = ?
) old
JOIN (
SELECT DISTINCT ig.group_id, ig.version, ig.crc32
FROM index_groups ig
WHERE ig.container_id IN (
SELECT i.container_id
FROM resolved_indexes i
WHERE i.scope_id = ? AND i.archive_id = ?
)
) new ON old.file_id = new.group_id AND old.version = new.version + 1
LEFT JOIN resolve_file(old.index_id, old.file_id, old.version, old.crc32) b ON TRUE
LEFT JOIN resolve_group(?, ?::uint1, new.group_id, new.crc32, new.version) c ON TRUE
WHERE (b.data IS NULL AND c.data IS NOT NULL) OR (b.data IS NOT NULL AND c.data IS NULL)
""".trimIndent()
).use { stmt ->
stmt.setInt(1, index)
stmt.setInt(2, scopeId)
stmt.setInt(3, archive)
stmt.setInt(4, scopeId)
stmt.setInt(5, archive)
stmt.executeQuery().use { rows ->
while (rows.next()) {
val id = rows.getInt(1)
val oldVersion = rows.getInt(2)
val oldChecksum = rows.getInt(3)
val newVersion = rows.getInt(5)
val newChecksum = rows.getInt(6)
val oldData = rows.getBytes(4)
if (oldData != null) {
Unpooled.wrappedBuffer(oldData).use { oldBuf ->
fileToGroup(oldBuf, newChecksum).use { newBuf ->
if (newBuf != null) {
val uncompressed = Js5Compression.uncompressUnlessEncrypted(newBuf.slice())
groups += CacheImporter.Group(
archive,
id,
newBuf.retain(),
uncompressed,
newVersion,
false
)
}
}
}
}
val newData = rows.getBytes(7)
if (newData != null) {
Unpooled.wrappedBuffer(newData).use { newBuf ->
val oldBuf = groupToFile(newBuf, oldChecksum)
if (oldBuf != null) {
files += CacheImporter.File(index, id, oldBuf, oldVersion)
}
}
}
}
}
}
if (groups.isEmpty() && files.isEmpty()) {
return
}
importer.prepare(connection)
val sourceId = importer.addSource(
connection,
type = CacheImporter.SourceType.CROSS_POLLINATION,
cacheId = null,
gameId = null,
buildMajor = null,
buildMinor = null,
timestamp = null,
name = null,
description = null,
url = null,
)
if (groups.isNotEmpty()) {
importer.addGroups(connection, scopeId, sourceId, groups)
}
if (files.isNotEmpty()) {
importer.addFiles(connection, sourceId, files)
}
} finally {
groups.forEach(CacheImporter.Group::release)
files.forEach(CacheImporter.File::release)
}
}
private fun getUncompressedLength(buf: ByteBuf): Int {
GZIPInputStream(ByteBufInputStream(buf)).use { input ->
var len = 0
val temp = ByteArray(4096)
while (true) {
val n = input.read(temp)
if (n == -1) {
break
}
len += n
}
return len
}
}
private fun fileToGroup(input: ByteBuf, expectedChecksum: Int): ByteBuf? {
val len = input.readableBytes()
val lenWithHeader = len + JS5_COMPRESSION_HEADER_LEN
val uncompressedLen = getUncompressedLength(input.slice())
alloc.buffer(lenWithHeader, lenWithHeader).use { output ->
output.writeByte(Js5CompressionType.GZIP.ordinal)
output.writeInt(len)
output.writeInt(uncompressedLen)
output.writeBytes(input)
return if (output.crc32() == expectedChecksum) {
output.retain()
} else {
null
}
}
}
private fun groupToFile(input: ByteBuf, expectedChecksum: Int): ByteBuf? {
val type = Js5CompressionType.fromOrdinal(input.readUnsignedByte().toInt())
if (type != Js5CompressionType.GZIP) {
return null
}
input.skipBytes(JS5_COMPRESSION_HEADER_LEN - 1)
return if (input.crc32() == expectedChecksum) {
input.retainedSlice()
} else {
null
}
}
private companion object {
private val OLD_TO_NEW_ENGINE = mapOf(
1 to 7, // MODELS
3 to 6, // MIDI_SONGS
4 to 5, // MAPS
)
private const val JS5_COMPRESSION_HEADER_LEN = 9
}
}

@ -2,6 +2,8 @@ package org.openrs2.archive.cache
import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.types.int import com.github.ajalt.clikt.parameters.types.int
import com.github.ajalt.clikt.parameters.types.path import com.github.ajalt.clikt.parameters.types.path
import com.google.inject.Guice import com.google.inject.Guice
@ -11,6 +13,7 @@ import org.openrs2.cache.DiskStore
import org.openrs2.inject.CloseableInjector import org.openrs2.inject.CloseableInjector
public class ExportCommand : CliktCommand(name = "export") { public class ExportCommand : CliktCommand(name = "export") {
private val scope by option().default("runescape")
private val id by argument().int() private val id by argument().int()
private val output by argument().path( private val output by argument().path(
mustExist = true, mustExist = true,
@ -23,7 +26,7 @@ public class ExportCommand : CliktCommand(name = "export") {
CloseableInjector(Guice.createInjector(ArchiveModule)).use { injector -> CloseableInjector(Guice.createInjector(ArchiveModule)).use { injector ->
val exporter = injector.getInstance(CacheExporter::class.java) val exporter = injector.getInstance(CacheExporter::class.java)
exporter.export(id) { legacy -> exporter.export(scope, id) { legacy ->
DiskStore.create(output, legacy = legacy) DiskStore.create(output, legacy = legacy)
} }
} }

@ -25,6 +25,7 @@ import kotlin.coroutines.resumeWithException
@ChannelHandler.Sharable @ChannelHandler.Sharable
public abstract class Js5ChannelHandler( public abstract class Js5ChannelHandler(
private val bootstrap: Bootstrap, private val bootstrap: Bootstrap,
private val scopeId: Int,
private val gameId: Int, private val gameId: Int,
private val hostname: String, private val hostname: String,
private val port: Int, private val port: Int,
@ -151,6 +152,7 @@ public abstract class Js5ChannelHandler(
} }
} }
@Suppress("OVERRIDE_DEPRECATION")
override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) { override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
releaseGroups() releaseGroups()
@ -236,7 +238,7 @@ public abstract class Js5ChannelHandler(
if (groups.size >= CacheImporter.BATCH_SIZE || complete) { if (groups.size >= CacheImporter.BATCH_SIZE || complete) {
runBlocking { runBlocking {
importer.importGroups(sourceId, groups) importer.importGroups(scopeId, sourceId, groups)
} }
releaseGroups() releaseGroups()
@ -268,6 +270,7 @@ public abstract class Js5ChannelHandler(
buf, buf,
uncompressed, uncompressed,
gameId, gameId,
scopeId,
buildMajor, buildMajor,
buildMinor, buildMinor,
lastMasterIndexId, lastMasterIndexId,
@ -315,7 +318,15 @@ public abstract class Js5ChannelHandler(
} }
val groups = runBlocking { val groups = runBlocking {
importer.importIndexAndGetMissingGroups(sourceId, archive, index, buf, uncompressed, lastMasterIndexId) importer.importIndexAndGetMissingGroups(
scopeId,
sourceId,
archive,
index,
buf,
uncompressed,
lastMasterIndexId
)
} }
for (group in groups) { for (group in groups) {
val groupEntry = index[group]!! val groupEntry = index[group]!!

@ -19,11 +19,12 @@ import org.openrs2.buffer.use
import org.openrs2.cache.MasterIndexFormat import org.openrs2.cache.MasterIndexFormat
import org.openrs2.protocol.Rs2Decoder import org.openrs2.protocol.Rs2Decoder
import org.openrs2.protocol.Rs2Encoder import org.openrs2.protocol.Rs2Encoder
import org.openrs2.protocol.js5.XorDecoder import org.openrs2.protocol.js5.downstream.XorDecoder
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
public class NxtJs5ChannelHandler( public class NxtJs5ChannelHandler(
bootstrap: Bootstrap, bootstrap: Bootstrap,
scopeId: Int,
gameId: Int, gameId: Int,
hostname: String, hostname: String,
port: Int, port: Int,
@ -38,6 +39,7 @@ public class NxtJs5ChannelHandler(
private val maxMinorBuildAttempts: Int = 5 private val maxMinorBuildAttempts: Int = 5
) : Js5ChannelHandler( ) : Js5ChannelHandler(
bootstrap, bootstrap,
scopeId,
gameId, gameId,
hostname, hostname,
port, port,

@ -6,26 +6,28 @@ import io.netty.channel.ChannelPipeline
import org.openrs2.cache.MasterIndexFormat import org.openrs2.cache.MasterIndexFormat
import org.openrs2.protocol.Rs2Decoder import org.openrs2.protocol.Rs2Decoder
import org.openrs2.protocol.Rs2Encoder import org.openrs2.protocol.Rs2Encoder
import org.openrs2.protocol.js5.Js5Request import org.openrs2.protocol.js5.downstream.Js5LoginResponse
import org.openrs2.protocol.js5.Js5RequestEncoder import org.openrs2.protocol.js5.downstream.Js5Response
import org.openrs2.protocol.js5.Js5Response import org.openrs2.protocol.js5.downstream.Js5ResponseDecoder
import org.openrs2.protocol.js5.Js5ResponseDecoder import org.openrs2.protocol.js5.downstream.XorDecoder
import org.openrs2.protocol.js5.XorDecoder import org.openrs2.protocol.js5.upstream.Js5Request
import org.openrs2.protocol.login.LoginRequest import org.openrs2.protocol.js5.upstream.Js5RequestEncoder
import org.openrs2.protocol.login.LoginResponse import org.openrs2.protocol.login.upstream.LoginRequest
import kotlin.coroutines.Continuation import kotlin.coroutines.Continuation
public class OsrsJs5ChannelHandler( public class OsrsJs5ChannelHandler(
bootstrap: Bootstrap, bootstrap: Bootstrap,
scopeId: Int,
gameId: Int, gameId: Int,
hostname: String, hostname: String,
port: Int, port: Int,
build: Int, build: Int,
lastMasterIndexId: Int?, lastMasterIndexId: Int?,
continuation: Continuation<Unit>, continuation: Continuation<Unit>,
importer: CacheImporter, importer: CacheImporter
) : Js5ChannelHandler( ) : Js5ChannelHandler(
bootstrap, bootstrap,
scopeId,
gameId, gameId,
hostname, hostname,
port, port,
@ -64,9 +66,9 @@ public class OsrsJs5ChannelHandler(
override fun channelRead0(ctx: ChannelHandlerContext, msg: Any) { override fun channelRead0(ctx: ChannelHandlerContext, msg: Any) {
when (msg) { when (msg) {
is LoginResponse.Js5Ok -> handleOk(ctx) is Js5LoginResponse.Ok -> handleOk(ctx)
is LoginResponse.ClientOutOfDate -> handleClientOutOfDate(ctx) is Js5LoginResponse.ClientOutOfDate -> handleClientOutOfDate(ctx)
is LoginResponse -> throw Exception("Invalid response: $msg") is Js5LoginResponse -> throw Exception("Invalid response: $msg")
is Js5Response -> handleResponse(ctx, msg.prefetch, msg.archive, msg.group, msg.data) is Js5Response -> handleResponse(ctx, msg.prefetch, msg.archive, msg.group, msg.data)
else -> throw Exception("Unknown message type: ${msg.javaClass.name}") else -> throw Exception("Unknown message type: ${msg.javaClass.name}")
} }

@ -6,13 +6,16 @@ import io.netty.handler.timeout.ReadTimeoutHandler
import org.openrs2.protocol.Protocol import org.openrs2.protocol.Protocol
import org.openrs2.protocol.Rs2Decoder import org.openrs2.protocol.Rs2Decoder
import org.openrs2.protocol.Rs2Encoder import org.openrs2.protocol.Rs2Encoder
import org.openrs2.protocol.js5.downstream.Js5ClientOutOfDateCodec
import org.openrs2.protocol.js5.downstream.Js5OkCodec
import org.openrs2.protocol.login.upstream.InitJs5RemoteConnectionCodec
public class OsrsJs5ChannelInitializer(private val handler: OsrsJs5ChannelHandler) : ChannelInitializer<Channel>() { public class OsrsJs5ChannelInitializer(private val handler: OsrsJs5ChannelHandler) : ChannelInitializer<Channel>() {
override fun initChannel(ch: Channel) { override fun initChannel(ch: Channel) {
ch.pipeline().addLast( ch.pipeline().addLast(
ReadTimeoutHandler(30), ReadTimeoutHandler(30),
Rs2Encoder(Protocol.LOGIN_UPSTREAM), Rs2Encoder(Protocol(InitJs5RemoteConnectionCodec())),
Rs2Decoder(Protocol.LOGIN_DOWNSTREAM) Rs2Decoder(Protocol(Js5OkCodec(), Js5ClientOutOfDateCodec()))
) )
ch.pipeline().addLast("handler", handler) ch.pipeline().addLast("handler", handler)
} }

@ -0,0 +1,149 @@
package org.openrs2.archive.cache.finder
import com.github.michaelbull.logging.InlineLogger
import com.google.common.io.ByteStreams
import com.google.common.io.LittleEndianDataInputStream
import org.openrs2.util.charset.Cp1252Charset
import java.io.Closeable
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
import java.io.PushbackInputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.attribute.BasicFileAttributeView
import java.nio.file.attribute.FileTime
import java.time.Instant
public class CacheFinderExtractor(
input: InputStream
) : Closeable {
private val pushbackInput = PushbackInputStream(input)
private val input = LittleEndianDataInputStream(pushbackInput)
private fun readTimestamp(): FileTime {
val lo = input.readInt().toLong() and 0xFFFFFFFF
val hi = input.readInt().toLong() and 0xFFFFFFFF
val seconds = (((hi shl 32) or lo) / 10_000_000) - FILETIME_TO_UNIX_EPOCH
return FileTime.from(Instant.ofEpochSecond(seconds, lo))
}
private fun readName(): String {
val bytes = ByteArray(MAX_PATH)
input.readFully(bytes)
var len = bytes.size
for ((i, b) in bytes.withIndex()) {
if (b.toInt() == 0) {
len = i
break
}
}
return String(bytes, 0, len, Cp1252Charset)
}
private fun peekUnsignedByte(): Int {
val n = pushbackInput.read()
pushbackInput.unread(n)
return n
}
public fun extract(destination: Path) {
val newVersion = peekUnsignedByte() == 0xFE
if (newVersion) {
val signature = input.readInt()
if (signature != 0x435352FE) {
throw IOException("Invalid signature")
}
}
var readDirectoryPath = true
var number = 0
var directorySuffix: String? = null
while (true) {
if (newVersion && readDirectoryPath) {
val len = try {
input.readInt()
} catch (ex: EOFException) {
break
}
val bytes = ByteArray(len)
input.readFully(bytes)
val path = String(bytes, Cp1252Charset)
logger.info { "Extracting $path" }
readDirectoryPath = false
directorySuffix = path.substring(path.lastIndexOf('\\') + 1)
.replace(INVALID_CHARS, "_")
continue
}
if (peekUnsignedByte() == 0xFF) {
input.skipBytes(1)
readDirectoryPath = true
number++
continue
}
val attributes = try {
input.readInt()
} catch (ex: EOFException) {
break
}
val btime = readTimestamp()
val atime = readTimestamp()
val mtime = readTimestamp()
val sizeHi = input.readInt().toLong() and 0xFFFFFFFF
val sizeLo = input.readInt().toLong() and 0xFFFFFFFF
val size = (sizeHi shl 32) or sizeLo
input.skipBytes(8) // reserved
val name = readName()
input.skipBytes(14) // alternate name
input.skipBytes(2) // padding
val dir = if (directorySuffix != null) {
destination.resolve("cache${number}_$directorySuffix")
} else {
destination.resolve("cache$number")
}
Files.createDirectories(dir)
if ((attributes and FILE_ATTRIBUTE_DIRECTORY) == 0) {
val file = dir.resolve(name)
Files.newOutputStream(file).use { output ->
ByteStreams.copy(ByteStreams.limit(input, size), output)
}
val view = Files.getFileAttributeView(file, BasicFileAttributeView::class.java)
view.setTimes(mtime, atime, btime)
}
}
}
override fun close() {
input.close()
}
private companion object {
private const val FILETIME_TO_UNIX_EPOCH: Long = 11644473600
private const val MAX_PATH = 260
private const val FILE_ATTRIBUTE_DIRECTORY = 0x10
private val INVALID_CHARS = Regex("[^A-Za-z0-9-]")
private val logger = InlineLogger()
}
}

@ -0,0 +1,25 @@
package org.openrs2.archive.cache.finder
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.arguments.default
import com.github.ajalt.clikt.parameters.types.inputStream
import com.github.ajalt.clikt.parameters.types.path
import java.nio.file.Path
public class ExtractCommand : CliktCommand(name = "extract") {
private val input by argument().inputStream()
private val output by argument().path(
mustExist = false,
canBeFile = false,
canBeDir = true,
mustBeReadable = true,
mustBeWritable = true
).default(Path.of("."))
override fun run() {
CacheFinderExtractor(input).use { extractor ->
extractor.extract(output)
}
}
}

@ -4,13 +4,11 @@ import io.netty.buffer.ByteBuf
import org.openrs2.buffer.readString import org.openrs2.buffer.readString
import org.openrs2.buffer.writeString import org.openrs2.buffer.writeString
import org.openrs2.crypto.StreamCipher import org.openrs2.crypto.StreamCipher
import org.openrs2.protocol.PacketCodec import org.openrs2.protocol.VariableBytePacketCodec
import org.openrs2.protocol.PacketLength
public object InitJs5RemoteConnectionCodec : PacketCodec<InitJs5RemoteConnection>( public object InitJs5RemoteConnectionCodec : VariableBytePacketCodec<InitJs5RemoteConnection>(
length = PacketLength.VARIABLE_BYTE, type = InitJs5RemoteConnection::class.java,
opcode = 15, opcode = 15
type = InitJs5RemoteConnection::class.java
) { ) {
override fun decode(input: ByteBuf, cipher: StreamCipher): InitJs5RemoteConnection { override fun decode(input: ByteBuf, cipher: StreamCipher): InitJs5RemoteConnection {
val buildMajor = input.readInt() val buildMajor = input.readInt()

@ -1,25 +1,8 @@
package org.openrs2.archive.cache.nxt package org.openrs2.archive.cache.nxt
import io.netty.buffer.ByteBuf import org.openrs2.protocol.EmptyPacketCodec
import org.openrs2.crypto.StreamCipher
import org.openrs2.protocol.PacketCodec
public object Js5OkCodec : PacketCodec<LoginResponse.Js5Ok>( public object Js5OkCodec : EmptyPacketCodec<LoginResponse.Js5Ok>(
opcode = 0, opcode = 0,
length = LoginResponse.Js5Ok.LOADING_REQUIREMENTS * 4, packet = LoginResponse.Js5Ok
type = LoginResponse.Js5Ok::class.java )
) {
override fun decode(input: ByteBuf, cipher: StreamCipher): LoginResponse.Js5Ok {
val loadingRequirements = mutableListOf<Int>()
for (i in 0 until LoginResponse.Js5Ok.LOADING_REQUIREMENTS) {
loadingRequirements += input.readInt()
}
return LoginResponse.Js5Ok(loadingRequirements)
}
override fun encode(input: LoginResponse.Js5Ok, output: ByteBuf, cipher: StreamCipher) {
for (requirement in input.loadingRequirements) {
output.writeInt(requirement)
}
}
}

@ -16,6 +16,7 @@ public object Js5RequestEncoder : MessageToByteEncoder<Js5Request>(Js5Request::c
out.writeShort(msg.build) out.writeShort(msg.build)
out.writeShort(0) out.writeShort(0)
} }
is Js5Request.Connected -> { is Js5Request.Connected -> {
out.writeByte(6) out.writeByte(6)
out.writeMedium(5) out.writeMedium(5)

@ -38,10 +38,10 @@ public class Js5ResponseDecoder : ByteToMessageDecoder() {
request = Request(prefetch, archive, group) request = Request(prefetch, archive, group)
if (buffers.containsKey(request)) { state = if (buffers.containsKey(request)) {
state = State.READ_DATA State.READ_DATA
} else { } else {
state = State.READ_LEN State.READ_LEN
} }
} }

@ -3,11 +3,6 @@ package org.openrs2.archive.cache.nxt
import org.openrs2.protocol.Packet import org.openrs2.protocol.Packet
public sealed class LoginResponse : Packet { public sealed class LoginResponse : Packet {
public data class Js5Ok(val loadingRequirements: List<Int>) : LoginResponse() { public object Js5Ok : LoginResponse()
public companion object {
public const val LOADING_REQUIREMENTS: Int = 31
}
}
public object ClientOutOfDate : LoginResponse() public object ClientOutOfDate : LoginResponse()
} }

@ -6,5 +6,6 @@ public data class Game(
public val buildMajor: Int?, public val buildMajor: Int?,
public val buildMinor: Int?, public val buildMinor: Int?,
public val lastMasterIndexId: Int?, public val lastMasterIndexId: Int?,
public val languageId: Int public val languageId: Int,
public val scopeId: Int
) )

@ -12,7 +12,7 @@ public class GameDatabase @Inject constructor(
return database.execute { connection -> return database.execute { connection ->
connection.prepareStatement( connection.prepareStatement(
""" """
SELECT v.id, v.url, v.build_major, v.build_minor, v.last_master_index_id, v.language_id SELECT v.id, v.url, v.build_major, v.build_minor, v.last_master_index_id, v.language_id, g.scope_id
FROM game_variants v FROM game_variants v
JOIN games g ON g.id = v.game_id JOIN games g ON g.id = v.game_id
JOIN environments e ON e.id = v.environment_id JOIN environments e ON e.id = v.environment_id
@ -48,8 +48,9 @@ public class GameDatabase @Inject constructor(
} }
val languageId = rows.getInt(6) val languageId = rows.getInt(6)
val scopeId = rows.getInt(7)
return@execute Game(id, url, buildMajor, buildMinor, lastMasterIndexId, languageId) return@execute Game(id, url, buildMajor, buildMinor, lastMasterIndexId, languageId, scopeId)
} }
} }
} }

@ -47,12 +47,14 @@ public data class JavConfig(
messages[parts[0]] = parts[1] messages[parts[0]] = parts[1]
} }
} }
line.startsWith("param=") -> { line.startsWith("param=") -> {
val parts = line.substring("param=".length).split("=", limit = 2) val parts = line.substring("param=".length).split("=", limit = 2)
if (parts.size == 2) { if (parts.size == 2) {
params[parts[0]] = parts[1] params[parts[0]] = parts[1]
} }
} }
else -> { else -> {
val parts = line.split("=", limit = 2) val parts = line.split("=", limit = 2)
if (parts.size == 2) { if (parts.size == 2) {

@ -0,0 +1,57 @@
package org.openrs2.archive.key
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.future.await
import kotlinx.coroutines.withContext
import org.openrs2.crypto.XteaKey
import org.openrs2.http.checkStatusCode
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
public class HdosKeyDownloader @Inject constructor(
private val client: HttpClient
) : KeyDownloader(KeySource.HDOS) {
override suspend fun getMissingUrls(seenUrls: Set<String>): Set<String> {
return setOf(ENDPOINT)
}
override suspend fun download(url: String): Sequence<XteaKey> {
val request = HttpRequest.newBuilder(URI(url))
.GET()
.timeout(Duration.ofSeconds(30))
.build()
val response = client.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()).await()
response.checkStatusCode()
return withContext(Dispatchers.IO) {
response.body().use { input ->
input.bufferedReader().use { reader ->
val keys = mutableSetOf<XteaKey>()
for (line in reader.lineSequence()) {
val parts = line.split(',')
if (parts.size < 3) {
continue
}
val key = XteaKey.fromHexOrNull(parts[2]) ?: continue
keys += key
}
keys.asSequence()
}
}
}
}
private companion object {
private const val ENDPOINT = "https://api.hdos.dev/keys/get"
}
}

@ -24,11 +24,13 @@ public class JsonKeyReader @Inject constructor(
keys += mapper.treeToValue<XteaKey?>(key) ?: throw IOException("Key must be non-null") keys += mapper.treeToValue<XteaKey?>(key) ?: throw IOException("Key must be non-null")
} }
} }
root.isObject -> { root.isObject -> {
for (entry in root.fields()) { for (entry in root.fields()) {
keys += mapper.treeToValue<XteaKey?>(entry.value) ?: throw IOException("Key must be non-null") keys += mapper.treeToValue<XteaKey?>(entry.value) ?: throw IOException("Key must be non-null")
} }
} }
else -> throw IOException("Root element must be an array or object") else -> throw IOException("Root element must be an array or object")
} }

@ -3,6 +3,7 @@ package org.openrs2.archive.key
import com.github.michaelbull.logging.InlineLogger import com.github.michaelbull.logging.InlineLogger
import org.openrs2.crypto.XteaKey import org.openrs2.crypto.XteaKey
import org.openrs2.db.Database import org.openrs2.db.Database
import java.io.IOException
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.sql.Connection import java.sql.Connection
@ -74,12 +75,17 @@ public class KeyImporter @Inject constructor(
val urls = mutableSetOf<String>() val urls = mutableSetOf<String>()
for (downloader in downloaders) { for (downloader in downloaders) {
try {
for (url in downloader.getMissingUrls(seenUrls)) { for (url in downloader.getMissingUrls(seenUrls)) {
keys += downloader.download(url).map { key -> keys += downloader.download(url).map { key ->
Key(key, downloader.source) Key(key, downloader.source)
} }
urls += url urls += url
} }
} catch (ex: IOException) {
logger.warn(ex) { "Failed to download keys from ${downloader.source.name}" }
continue
}
} }
database.execute { connection -> database.execute { connection ->

@ -5,5 +5,6 @@ public enum class KeySource {
DISK, DISK,
OPENOSRS, OPENOSRS,
POLAR, POLAR,
RUNELITE RUNELITE,
HDOS
} }

@ -1,19 +0,0 @@
package org.openrs2.archive.key
import java.net.http.HttpClient
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
public class OpenOsrsKeyDownloader @Inject constructor(
client: HttpClient,
jsonKeyReader: JsonKeyReader
) : JsonKeyDownloader(KeySource.OPENOSRS, client, jsonKeyReader) {
override suspend fun getMissingUrls(seenUrls: Set<String>): Set<String> {
return setOf(ENDPOINT)
}
private companion object {
private const val ENDPOINT = "https://xtea.openosrs.dev/get"
}
}

@ -1,50 +0,0 @@
package org.openrs2.archive.key
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.future.await
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.openrs2.http.charset
import org.openrs2.http.checkStatusCode
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
public class PolarKeyDownloader @Inject constructor(
private val client: HttpClient,
jsonKeyReader: JsonKeyReader
) : JsonKeyDownloader(KeySource.POLAR, client, jsonKeyReader) {
override suspend fun getMissingUrls(seenUrls: Set<String>): Set<String> {
val request = HttpRequest.newBuilder(ENDPOINT)
.GET()
.timeout(Duration.ofSeconds(30))
.build()
val response = client.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream()).await()
response.checkStatusCode()
val document = withContext(Dispatchers.IO) {
Jsoup.parse(response.body(), response.charset?.name(), ENDPOINT.toString())
}
val urls = mutableSetOf<String>()
for (element in document.select("a")) {
val url = element.absUrl("href")
if (url.endsWith(".json") && url !in seenUrls) {
urls += url
}
}
return urls
}
private companion object {
private val ENDPOINT = URI("https://archive.runestats.com/osrs/xtea/")
}
}

@ -31,10 +31,28 @@ public class MapRenderer @Inject constructor(
val fillColor = Color(outlineColor.red, outlineColor.green, outlineColor.blue, 128) val fillColor = Color(outlineColor.red, outlineColor.green, outlineColor.blue, 128)
} }
public suspend fun render(masterIndexId: Int): BufferedImage { public suspend fun render(scope: String, masterIndexId: Int): BufferedImage {
return database.execute { connection -> return database.execute { connection ->
val scopeId = connection.prepareStatement(
"""
SELECT id
FROM scopes
WHERE name = ?
""".trimIndent()
).use { stmt ->
stmt.setString(1, scope)
stmt.executeQuery().use { rows ->
if (!rows.next()) {
throw IllegalArgumentException("Invalid scope")
}
rows.getInt(1)
}
}
// read config index // read config index
val configIndex = readIndex(connection, masterIndexId, Js5Archive.CONFIG) val configIndex = readIndex(connection, scopeId, masterIndexId, Js5Archive.CONFIG)
?: throw IllegalArgumentException("Config index missing") ?: throw IllegalArgumentException("Config index missing")
// read FluType group // read FluType group
@ -43,7 +61,7 @@ public class MapRenderer @Inject constructor(
val underlayGroup = configIndex[Js5ConfigGroup.FLUTYPE] val underlayGroup = configIndex[Js5ConfigGroup.FLUTYPE]
?: throw IllegalArgumentException("FluType group missing in index") ?: throw IllegalArgumentException("FluType group missing in index")
val underlayFiles = readGroup(connection, masterIndexId, Js5Archive.CONFIG, underlayGroup) val underlayFiles = readGroup(connection, scopeId, masterIndexId, Js5Archive.CONFIG, underlayGroup)
?: throw IllegalArgumentException("FluType group missing") ?: throw IllegalArgumentException("FluType group missing")
try { try {
for ((id, file) in underlayFiles) { for ((id, file) in underlayFiles) {
@ -59,7 +77,7 @@ public class MapRenderer @Inject constructor(
val overlayGroup = configIndex[Js5ConfigGroup.FLOTYPE] val overlayGroup = configIndex[Js5ConfigGroup.FLOTYPE]
?: throw IllegalArgumentException("FloType group missing in index") ?: throw IllegalArgumentException("FloType group missing in index")
val overlayFiles = readGroup(connection, masterIndexId, Js5Archive.CONFIG, overlayGroup) val overlayFiles = readGroup(connection, scopeId, masterIndexId, Js5Archive.CONFIG, overlayGroup)
?: throw IllegalArgumentException("FloType group missing") ?: throw IllegalArgumentException("FloType group missing")
try { try {
for ((id, file) in overlayFiles) { for ((id, file) in overlayFiles) {
@ -71,13 +89,13 @@ public class MapRenderer @Inject constructor(
// read textures // read textures
val textures = mutableMapOf<Int, Int>() val textures = mutableMapOf<Int, Int>()
val materialsIndex = readIndex(connection, masterIndexId, Js5Archive.MATERIALS) val materialsIndex = readIndex(connection, scopeId, masterIndexId, Js5Archive.MATERIALS)
if (materialsIndex != null) { if (materialsIndex != null) {
val materialsGroup = materialsIndex[0] val materialsGroup = materialsIndex[0]
?: throw IllegalArgumentException("Materials group missing in index") ?: throw IllegalArgumentException("Materials group missing in index")
val materialsFiles = readGroup(connection, masterIndexId, Js5Archive.MATERIALS, materialsGroup) val materialsFiles = readGroup(connection, scopeId, masterIndexId, Js5Archive.MATERIALS, materialsGroup)
?: throw IllegalArgumentException("Materials group missing") ?: throw IllegalArgumentException("Materials group missing")
try { try {
val metadata = materialsFiles[0] val metadata = materialsFiles[0]
@ -123,13 +141,13 @@ public class MapRenderer @Inject constructor(
materialsFiles.values.forEach(ByteBuf::release) materialsFiles.values.forEach(ByteBuf::release)
} }
} else { } else {
val textureIndex = readIndex(connection, masterIndexId, Js5Archive.TEXTURES) val textureIndex = readIndex(connection, scopeId, masterIndexId, Js5Archive.TEXTURES)
?: throw IllegalArgumentException("Textures index missing") ?: throw IllegalArgumentException("Textures index missing")
val textureGroup = textureIndex[0] val textureGroup = textureIndex[0]
?: throw IllegalArgumentException("Textures group missing from index") ?: throw IllegalArgumentException("Textures group missing from index")
val textureFiles = readGroup(connection, masterIndexId, Js5Archive.TEXTURES, textureGroup) val textureFiles = readGroup(connection, scopeId, masterIndexId, Js5Archive.TEXTURES, textureGroup)
?: throw IllegalArgumentException("Textures group missing") ?: throw IllegalArgumentException("Textures group missing")
try { try {
for ((id, file) in textureFiles) { for ((id, file) in textureFiles) {
@ -155,11 +173,12 @@ public class MapRenderer @Inject constructor(
SELECT n.name, g.encrypted, g.empty_loc, g.key_id SELECT n.name, g.encrypted, g.empty_loc, g.key_id
FROM resolved_groups g FROM resolved_groups g
JOIN names n ON n.hash = g.name_hash JOIN names n ON n.hash = g.name_hash
WHERE g.master_index_id = ? AND g.archive_id = ${Js5Archive.MAPS} AND WHERE g.scope_id = ? AND g.master_index_id = ? AND g.archive_id = ${Js5Archive.MAPS} AND
n.name ~ '^[lm](?:[0-9]|[1-9][0-9])_(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$' n.name ~ '^[lm](?:[0-9]|[1-9][0-9])_(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$'
""".trimIndent() """.trimIndent()
).use { stmt -> ).use { stmt ->
stmt.setInt(1, masterIndexId) stmt.setInt(1, scopeId)
stmt.setInt(2, masterIndexId)
stmt.executeQuery().use { rows -> stmt.executeQuery().use { rows ->
while (rows.next()) { while (rows.next()) {
@ -207,11 +226,12 @@ public class MapRenderer @Inject constructor(
SELECT n.name, g.data SELECT n.name, g.data
FROM resolved_groups g FROM resolved_groups g
JOIN names n ON n.hash = g.name_hash JOIN names n ON n.hash = g.name_hash
WHERE g.master_index_id = ? AND g.archive_id = ${Js5Archive.MAPS} AND WHERE g.scope_id = ? AND g.master_index_id = ? AND g.archive_id = ${Js5Archive.MAPS} AND
n.name ~ '^m(?:[0-9]|[1-9][0-9])_(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$' n.name ~ '^m(?:[0-9]|[1-9][0-9])_(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$'
""".trimIndent() """.trimIndent()
).use { stmt -> ).use { stmt ->
stmt.setInt(1, masterIndexId) stmt.setInt(1, scopeId)
stmt.setInt(2, masterIndexId)
stmt.executeQuery().use { rows -> stmt.executeQuery().use { rows ->
while (rows.next()) { while (rows.next()) {
@ -246,16 +266,17 @@ public class MapRenderer @Inject constructor(
} }
} }
private fun readIndex(connection: Connection, masterIndexId: Int, archiveId: Int): Js5Index? { private fun readIndex(connection: Connection, scopeId: Int, masterIndexId: Int, archiveId: Int): Js5Index? {
connection.prepareStatement( connection.prepareStatement(
""" """
SELECT data SELECT data
FROM resolved_indexes FROM resolved_indexes
WHERE master_index_id = ? AND archive_id = ? WHERE scope_id = ? AND master_index_id = ? AND archive_id = ?
""".trimIndent() """.trimIndent()
).use { stmt -> ).use { stmt ->
stmt.setInt(1, masterIndexId) stmt.setInt(1, scopeId)
stmt.setInt(2, archiveId) stmt.setInt(2, masterIndexId)
stmt.setInt(3, archiveId)
stmt.executeQuery().use { rows -> stmt.executeQuery().use { rows ->
if (!rows.next()) { if (!rows.next()) {
@ -275,6 +296,7 @@ public class MapRenderer @Inject constructor(
private fun readGroup( private fun readGroup(
connection: Connection, connection: Connection,
scopeId: Int,
masterIndexId: Int, masterIndexId: Int,
archiveId: Int, archiveId: Int,
group: Js5Index.Group<*> group: Js5Index.Group<*>
@ -283,12 +305,13 @@ public class MapRenderer @Inject constructor(
""" """
SELECT data SELECT data
FROM resolved_groups FROM resolved_groups
WHERE master_index_id = ? AND archive_id = ? AND group_id = ? WHERE scope_id = ? AND master_index_id = ? AND archive_id = ? AND group_id = ?
""".trimIndent() """.trimIndent()
).use { stmt -> ).use { stmt ->
stmt.setInt(1, masterIndexId) stmt.setInt(1, scopeId)
stmt.setInt(2, archiveId) stmt.setInt(2, masterIndexId)
stmt.setInt(3, group.id) stmt.setInt(3, archiveId)
stmt.setInt(4, group.id)
stmt.executeQuery().use { rows -> stmt.executeQuery().use { rows ->
if (!rows.next()) { if (!rows.next()) {
@ -321,6 +344,40 @@ public class MapRenderer @Inject constructor(
} }
} }
private fun isShortCode(buf: ByteBuf): Boolean {
for (plane in 0 until LEVELS) {
for (dx in 0 until MAP_SIZE) {
for (dz in 0 until MAP_SIZE) {
while (true) {
if (buf.readableBytes() < 2) {
return false
}
val code = buf.readUnsignedShort()
if (code == 0) {
break
} else if (code == 1) {
if (!buf.isReadable) {
return false
}
buf.skipBytes(1)
break
} else if (code <= 49) {
if (buf.readableBytes() < 2) {
return false
}
buf.skipBytes(2)
}
}
}
}
}
return !buf.isReadable
}
private fun renderMap( private fun renderMap(
image: BufferedImage, image: BufferedImage,
x: Int, x: Int,
@ -329,6 +386,12 @@ public class MapRenderer @Inject constructor(
underlayColors: Map<Int, Int>, underlayColors: Map<Int, Int>,
overlayColors: Map<Int, Int> overlayColors: Map<Int, Int>
) { ) {
val readCode = if (isShortCode(buf.slice())) {
buf::readUnsignedShort
} else {
{ buf.readUnsignedByte().toInt() }
}
for (plane in 0 until LEVELS) { for (plane in 0 until LEVELS) {
for (dx in 0 until MAP_SIZE) { for (dx in 0 until MAP_SIZE) {
for (dz in 0 until MAP_SIZE) { for (dz in 0 until MAP_SIZE) {
@ -337,14 +400,14 @@ public class MapRenderer @Inject constructor(
var underlay = 0 var underlay = 0
while (true) { while (true) {
val code = buf.readUnsignedByte().toInt() val code = readCode()
if (code == 0) { if (code == 0) {
break break
} else if (code == 1) { } else if (code == 1) {
buf.skipBytes(1) buf.skipBytes(1)
break break
} else if (code <= 49) { } else if (code <= 49) {
overlay = buf.readUnsignedByte().toInt() overlay = readCode()
shape = (code - 2) shr 2 shape = (code - 2) shr 2
} else if (code <= 81) { } else if (code <= 81) {
// empty // empty

@ -27,6 +27,8 @@ public class RuneStarNameDownloader @Inject constructor(
names += readTsv(endpoint, 0) names += readTsv(endpoint, 0)
} }
names += readTsv(LEANBOW_NAMES_ENDPOINT, 1)
return names.asSequence() return names.asSequence()
} }
@ -50,12 +52,18 @@ public class RuneStarNameDownloader @Inject constructor(
private companion object { private companion object {
private val NAMES_ENDPOINTS = listOf( private val NAMES_ENDPOINTS = listOf(
URI("https://raw.githubusercontent.com/RuneStar/cache-names/master/names.tsv"),
URI("https://raw.githubusercontent.com/Joshua-F/cache-names/master/names.tsv"), URI("https://raw.githubusercontent.com/Joshua-F/cache-names/master/names.tsv"),
URI("https://raw.githubusercontent.com/Pazaz/RT4-Data/main/names.tsv"),
URI("https://raw.githubusercontent.com/Pazaz/RT4-Data/main/osrs.tsv"),
URI("https://raw.githubusercontent.com/Pazaz/RT4-Data/main/walied.tsv"),
URI("https://raw.githubusercontent.com/RuneStar/cache-names/master/names.tsv"),
) )
private val INDIVIDUAL_NAMES_ENDPOINTS = listOf( private val INDIVIDUAL_NAMES_ENDPOINTS = listOf(
URI("https://raw.githubusercontent.com/RuneStar/cache-names/master/individual-names.tsv"),
URI("https://raw.githubusercontent.com/Joshua-F/cache-names/master/individual-names.tsv"), URI("https://raw.githubusercontent.com/Joshua-F/cache-names/master/individual-names.tsv"),
URI("https://raw.githubusercontent.com/Pazaz/RT4-Data/main/walied.individual.components.tsv"),
URI("https://raw.githubusercontent.com/Pazaz/RT4-Data/main/walied.individual.tsv"),
URI("https://raw.githubusercontent.com/RuneStar/cache-names/master/individual-names.tsv"),
) )
private val LEANBOW_NAMES_ENDPOINT = URI("https://raw.githubusercontent.com/Pazaz/RT4-Data/main/leanbow.tsv")
} }
} }

@ -1,25 +1,38 @@
package org.openrs2.archive.web package org.openrs2.archive.web
import io.ktor.application.ApplicationCall import io.ktor.http.CacheControl
import io.ktor.http.ContentDisposition import io.ktor.http.ContentDisposition
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.response.header import io.ktor.http.content.EntityTagVersion
import io.ktor.response.respond import io.ktor.http.content.caching
import io.ktor.response.respondOutputStream import io.ktor.http.content.versions
import io.ktor.thymeleaf.ThymeleafContent import io.ktor.server.application.ApplicationCall
import io.ktor.server.http.content.CachingOptions
import io.ktor.server.plugins.cachingheaders.caching
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytes
import io.ktor.server.response.respondOutputStream
import io.ktor.server.thymeleaf.ThymeleafContent
import io.netty.buffer.ByteBufAllocator import io.netty.buffer.ByteBufAllocator
import io.netty.buffer.ByteBufUtil
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream
import org.openrs2.archive.cache.CacheExporter import org.openrs2.archive.cache.CacheExporter
import org.openrs2.archive.map.MapRenderer import org.openrs2.archive.map.MapRenderer
import org.openrs2.buffer.use
import org.openrs2.cache.DiskStoreZipWriter import org.openrs2.cache.DiskStoreZipWriter
import org.openrs2.cache.FlatFileStoreTarWriter import org.openrs2.cache.FlatFileStoreTarWriter
import org.openrs2.compress.gzip.GzipLevelOutputStream import org.openrs2.compress.gzip.GzipLevelOutputStream
import org.openrs2.crypto.whirlpool
import java.nio.file.attribute.FileTime import java.nio.file.attribute.FileTime
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.util.Base64
import java.util.zip.Deflater import java.util.zip.Deflater
import java.util.zip.ZipEntry import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
@ -37,93 +50,186 @@ public class CachesController @Inject constructor(
public suspend fun index(call: ApplicationCall) { public suspend fun index(call: ApplicationCall) {
val caches = exporter.list() val caches = exporter.list()
call.respond(ThymeleafContent("caches/index.html", mapOf("caches" to caches))) val totalSize = exporter.totalSize()
call.respond(
ThymeleafContent(
"caches/index.html", mapOf(
"caches" to caches,
"totalSize" to totalSize
)
)
)
} }
public suspend fun indexJson(call: ApplicationCall) { public suspend fun indexJson(call: ApplicationCall) {
val caches = exporter.list() val caches = exporter.list()
call.caching = CachingOptions(
cacheControl = CacheControl.MaxAge(
maxAgeSeconds = 900,
visibility = CacheControl.Visibility.Public
),
expires = ZonedDateTime.now(ZoneOffset.UTC).plusSeconds(900)
)
call.respond(caches) call.respond(caches)
} }
public suspend fun show(call: ApplicationCall) { public suspend fun show(call: ApplicationCall) {
val scope = call.parameters["scope"]!!
val id = call.parameters["id"]?.toIntOrNull() val id = call.parameters["id"]?.toIntOrNull()
if (id == null) { if (id == null) {
call.respond(HttpStatusCode.NotFound) call.respond(HttpStatusCode.NotFound)
return return
} }
val cache = exporter.get(id) val cache = exporter.get(scope, id)
if (cache == null) { if (cache == null) {
call.respond(HttpStatusCode.NotFound) call.respond(HttpStatusCode.NotFound)
return return
} }
call.respond(ThymeleafContent("caches/show.html", mapOf("cache" to cache))) call.respond(
ThymeleafContent(
"caches/show.html", mapOf(
"cache" to cache,
"scope" to scope
)
)
)
}
public suspend fun exportGroup(call: ApplicationCall) {
val scope = call.parameters["scope"]!!
val id = call.parameters["id"]?.toIntOrNull()
val archiveId = call.parameters["archive"]?.toIntOrNull()
val groupId = call.parameters["group"]?.toIntOrNull()
if (id == null || archiveId == null || groupId == null) {
call.respond(HttpStatusCode.NotFound)
return
}
exporter.exportGroup(scope, id, archiveId, groupId).use { buf ->
if (buf == null) {
call.respond(HttpStatusCode.NotFound)
return
}
val etag = Base64.getEncoder().encodeToString(buf.whirlpool().sliceArray(0 until 16))
val bytes = ByteBufUtil.getBytes(buf, 0, buf.readableBytes(), false)
call.respondBytes(bytes, contentType = ContentType.Application.OctetStream) {
caching = CachingOptions(
cacheControl = CacheControl.MaxAge(
maxAgeSeconds = 86400,
visibility = CacheControl.Visibility.Public
),
expires = ZonedDateTime.now(ZoneOffset.UTC).plusSeconds(86400)
)
versions = listOf(
EntityTagVersion(etag, weak = false)
)
}
}
} }
public suspend fun exportDisk(call: ApplicationCall) { public suspend fun exportDisk(call: ApplicationCall) {
val scope = call.parameters["scope"]!!
val id = call.parameters["id"]?.toIntOrNull() val id = call.parameters["id"]?.toIntOrNull()
if (id == null) { if (id == null) {
call.respond(HttpStatusCode.NotFound) call.respond(HttpStatusCode.NotFound)
return return
} }
val name = exporter.getFileName(scope, id)
if (name == null) {
call.respond(HttpStatusCode.NotFound)
return
}
call.response.header( call.response.header(
HttpHeaders.ContentDisposition, HttpHeaders.ContentDisposition,
ContentDisposition.Attachment ContentDisposition.Attachment
.withParameter(ContentDisposition.Parameters.FileName, "cache.zip") .withParameter(ContentDisposition.Parameters.FileName, "cache-$name.zip")
.toString() .toString()
) )
call.respondOutputStream(contentType = ContentType.Application.Zip) { call.respondOutputStream(contentType = ContentType.Application.Zip) {
exporter.export(id) { legacy -> exporter.export(scope, id) { legacy ->
DiskStoreZipWriter(ZipOutputStream(this), alloc = alloc, legacy = legacy) DiskStoreZipWriter(ZipOutputStream(this), alloc = alloc, legacy = legacy)
} }
} }
} }
public suspend fun exportFlatFile(call: ApplicationCall) { public suspend fun exportFlatFile(call: ApplicationCall) {
val scope = call.parameters["scope"]!!
val id = call.parameters["id"]?.toIntOrNull() val id = call.parameters["id"]?.toIntOrNull()
if (id == null) { if (id == null) {
call.respond(HttpStatusCode.NotFound) call.respond(HttpStatusCode.NotFound)
return return
} }
val name = exporter.getFileName(scope, id)
if (name == null) {
call.respond(HttpStatusCode.NotFound)
return
}
call.response.header( call.response.header(
HttpHeaders.ContentDisposition, HttpHeaders.ContentDisposition,
ContentDisposition.Attachment ContentDisposition.Attachment
.withParameter(ContentDisposition.Parameters.FileName, "cache.tar.gz") .withParameter(ContentDisposition.Parameters.FileName, "cache-$name.tar.gz")
.toString() .toString()
) )
call.respondOutputStream(contentType = ContentType.Application.GZip) { call.respondOutputStream(contentType = ContentType.Application.GZip) {
exporter.export(id) { exporter.export(scope, id) {
FlatFileStoreTarWriter(TarArchiveOutputStream(GzipLevelOutputStream(this, Deflater.BEST_COMPRESSION))) FlatFileStoreTarWriter(TarArchiveOutputStream(GzipLevelOutputStream(this, Deflater.BEST_COMPRESSION)))
} }
} }
} }
public suspend fun exportKeysJson(call: ApplicationCall) { public suspend fun exportKeysJson(call: ApplicationCall) {
val scope = call.parameters["scope"]!!
val id = call.parameters["id"]?.toIntOrNull() val id = call.parameters["id"]?.toIntOrNull()
if (id == null) { if (id == null) {
call.respond(HttpStatusCode.NotFound) call.respond(HttpStatusCode.NotFound)
return return
} }
call.respond(exporter.exportKeys(id)) val name = exporter.getFileName(scope, id)
if (name == null) {
call.respond(HttpStatusCode.NotFound)
return
}
call.response.header(
HttpHeaders.ContentDisposition,
ContentDisposition.Inline
.withParameter(ContentDisposition.Parameters.FileName, "keys-$name.json")
.toString()
)
call.respond(exporter.exportKeys(scope, id))
} }
public suspend fun exportKeysZip(call: ApplicationCall) { public suspend fun exportKeysZip(call: ApplicationCall) {
val scope = call.parameters["scope"]!!
val id = call.parameters["id"]?.toIntOrNull() val id = call.parameters["id"]?.toIntOrNull()
if (id == null) { if (id == null) {
call.respond(HttpStatusCode.NotFound) call.respond(HttpStatusCode.NotFound)
return return
} }
val name = exporter.getFileName(scope, id)
if (name == null) {
call.respond(HttpStatusCode.NotFound)
return
}
call.response.header( call.response.header(
HttpHeaders.ContentDisposition, HttpHeaders.ContentDisposition,
ContentDisposition.Attachment ContentDisposition.Attachment
.withParameter(ContentDisposition.Parameters.FileName, "keys.zip") .withParameter(ContentDisposition.Parameters.FileName, "keys-$name.zip")
.toString() .toString()
) )
@ -134,7 +240,7 @@ public class CachesController @Inject constructor(
val timestamp = FileTime.from(Instant.EPOCH) val timestamp = FileTime.from(Instant.EPOCH)
for (key in exporter.exportKeys(id)) { for (key in exporter.exportKeys(scope, id)) {
if (key.mapSquare == null) { if (key.mapSquare == null) {
continue continue
} }
@ -166,19 +272,33 @@ public class CachesController @Inject constructor(
} }
public suspend fun renderMap(call: ApplicationCall) { public suspend fun renderMap(call: ApplicationCall) {
val scope = call.parameters["scope"]!!
val id = call.parameters["id"]?.toIntOrNull() val id = call.parameters["id"]?.toIntOrNull()
if (id == null) { if (id == null) {
call.respond(HttpStatusCode.NotFound) call.respond(HttpStatusCode.NotFound)
return return
} }
val name = exporter.getFileName(scope, id)
if (name == null) {
call.respond(HttpStatusCode.NotFound)
return
}
call.response.header(
HttpHeaders.ContentDisposition,
ContentDisposition.Inline
.withParameter(ContentDisposition.Parameters.FileName, "map-$name.png")
.toString()
)
/* /*
* The temporary BufferedImages used by the MapRenderer use a large * The temporary BufferedImages used by the MapRenderer use a large
* amount of heap space. We limit the number of renders that can be * amount of heap space. We limit the number of renders that can be
* performed in parallel to prevent OOMs. * performed in parallel to prevent OOMs.
*/ */
renderSemaphore.withPermit { renderSemaphore.withPermit {
val image = renderer.render(id) val image = renderer.render(scope, id)
call.respondOutputStream(contentType = ContentType.Image.PNG) { call.respondOutputStream(contentType = ContentType.Image.PNG) {
ImageIO.write(image, "PNG", this) ImageIO.write(image, "PNG", this)

@ -1,10 +1,10 @@
package org.openrs2.archive.web package org.openrs2.archive.web
import io.ktor.application.ApplicationCall
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.request.receive import io.ktor.server.application.ApplicationCall
import io.ktor.response.respond import io.ktor.server.request.receive
import io.ktor.thymeleaf.ThymeleafContent import io.ktor.server.response.respond
import io.ktor.server.thymeleaf.ThymeleafContent
import org.openrs2.archive.key.KeyExporter import org.openrs2.archive.key.KeyExporter
import org.openrs2.archive.key.KeyImporter import org.openrs2.archive.key.KeyImporter
import org.openrs2.archive.key.KeySource import org.openrs2.archive.key.KeySource

@ -1,25 +1,32 @@
package org.openrs2.archive.web package org.openrs2.archive.web
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import io.ktor.application.call
import io.ktor.application.install
import io.ktor.features.ContentNegotiation
import io.ktor.features.XForwardedHeaderSupport
import io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpHeaders
import io.ktor.http.content.resources import io.ktor.serialization.jackson.JacksonConverter
import io.ktor.http.content.static import io.ktor.server.application.ApplicationCall
import io.ktor.jackson.JacksonConverter import io.ktor.server.application.call
import io.ktor.response.respond import io.ktor.server.application.createApplicationPlugin
import io.ktor.response.respondRedirect import io.ktor.server.application.install
import io.ktor.routing.get
import io.ktor.routing.post
import io.ktor.routing.routing
import io.ktor.server.cio.CIO
import io.ktor.server.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.thymeleaf.Thymeleaf import io.ktor.server.http.content.resources
import io.ktor.thymeleaf.ThymeleafContent import io.ktor.server.http.content.static
import io.ktor.webjars.Webjars import io.ktor.server.jetty.Jetty
import io.ktor.server.plugins.autohead.AutoHeadResponse
import io.ktor.server.plugins.cachingheaders.CachingHeaders
import io.ktor.server.plugins.conditionalheaders.ConditionalHeaders
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.defaultheaders.DefaultHeaders
import io.ktor.server.plugins.forwardedheaders.XForwardedHeaders
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondRedirect
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import io.ktor.server.thymeleaf.Thymeleaf
import io.ktor.server.thymeleaf.ThymeleafContent
import io.ktor.server.webjars.Webjars
import org.openrs2.json.Json import org.openrs2.json.Json
import org.thymeleaf.extras.java8time.dialect.Java8TimeDialect import org.thymeleaf.extras.java8time.dialect.Java8TimeDialect
import org.thymeleaf.templatemode.TemplateMode import org.thymeleaf.templatemode.TemplateMode
@ -34,11 +41,25 @@ public class WebServer @Inject constructor(
@Json private val mapper: ObjectMapper @Json private val mapper: ObjectMapper
) { ) {
public fun start(address: String, port: Int) { public fun start(address: String, port: Int) {
embeddedServer(CIO, host = address, port = port) { embeddedServer(Jetty, host = address, port = port) {
install(AutoHeadResponse)
install(CachingHeaders)
install(ConditionalHeaders)
install(createApplicationPlugin(name = "CORS") {
onCall { call ->
call.response.header(HttpHeaders.AccessControlAllowOrigin, "*")
}
})
install(ContentNegotiation) { install(ContentNegotiation) {
ignoreType<ThymeleafContent>()
register(ContentType.Application.Json, JacksonConverter(mapper)) register(ContentType.Application.Json, JacksonConverter(mapper))
} }
install(DefaultHeaders)
install(Thymeleaf) { install(Thymeleaf) {
addDialect(ByteUnitsDialect) addDialect(ByteUnitsDialect)
addDialect(Java8TimeDialect()) addDialect(Java8TimeDialect())
@ -49,47 +70,52 @@ public class WebServer @Inject constructor(
}) })
} }
install(XForwardedHeaderSupport) install(XForwardedHeaders)
install(Webjars) install(Webjars)
routing { routing {
get("/") { call.respond(ThymeleafContent("index.html", emptyMap())) } get("/") { call.respond(ThymeleafContent("index.html", emptyMap())) }
get("/api") { call.respond(ThymeleafContent("api/index.html", mapOf("active" to "api"))) }
get("/caches") { cachesController.index(call) } get("/caches") { cachesController.index(call) }
get("/caches.json") { cachesController.indexJson(call) } get("/caches.json") { cachesController.indexJson(call) }
get("/caches/{id}") { cachesController.show(call) } get("/caches/{scope}/{id}") { cachesController.show(call) }
get("/caches/{id}.zip") { get("/caches/{scope}/{id}/archives/{archive}/groups/{group}.dat") { cachesController.exportGroup(call) }
val id = call.parameters["id"] get("/caches/{scope}/{id}/disk.zip") { cachesController.exportDisk(call) }
if (id == null) { get("/caches/{scope}/{id}/flat-file.tar.gz") { cachesController.exportFlatFile(call) }
call.respond(HttpStatusCode.NotFound) get("/caches/{scope}/{id}/keys.json") { cachesController.exportKeysJson(call) }
return@get get("/caches/{scope}/{id}/keys.zip") { cachesController.exportKeysZip(call) }
} get("/caches/{scope}/{id}/map.png") { cachesController.renderMap(call) }
call.respondRedirect(permanent = true) {
path("caches", id, "disk.zip")
}
}
get("/caches/{id}.json") {
val id = call.parameters["id"]
if (id == null) {
call.respond(HttpStatusCode.NotFound)
return@get
}
call.respondRedirect(permanent = true) {
path("caches", id, "keys.json")
}
}
get("/caches/{id}/disk.zip") { cachesController.exportDisk(call) }
get("/caches/{id}/flat-file.tar.gz") { cachesController.exportFlatFile(call) }
get("/caches/{id}/keys.json") { cachesController.exportKeysJson(call) }
get("/caches/{id}/keys.zip") { cachesController.exportKeysZip(call) }
get("/caches/{id}/map.png") { cachesController.renderMap(call) }
get("/keys") { keysController.index(call) } get("/keys") { keysController.index(call) }
post("/keys") { keysController.import(call) } post("/keys") { keysController.import(call) }
get("/keys/all.json") { keysController.exportAll(call) } get("/keys/all.json") { keysController.exportAll(call) }
get("/keys/valid.json") { keysController.exportValid(call) } get("/keys/valid.json") { keysController.exportValid(call) }
static("/static") { resources("/org/openrs2/archive/static") } static("/static") { resources("/org/openrs2/archive/static") }
// compatibility redirects
get("/caches/{id}") { redirect(call, permanent = true, "/caches/runescape/{id}") }
get("/caches/{id}.json") { redirect(call, permanent = true, "/caches/runescape/{id}/keys.json") }
get("/caches/{id}.zip") { redirect(call, permanent = true, "/caches/runescape/{id}/disk.zip") }
get("/caches/{id}/disk.zip") { redirect(call, permanent = true, "/caches/runescape/{id}/disk.zip") }
get("/caches/{id}/flat-file.tar.gz") {
redirect(call, permanent = true, "/caches/runescape/{id}/flat-file.tar.gz")
}
get("/caches/{id}/keys.json") { redirect(call, permanent = true, "/caches/runescape/{id}/keys.json") }
get("/caches/{id}/keys.zip") { redirect(call, permanent = true, "/caches/runescape/{id}/keys.zip") }
get("/caches/{id}/map.png") { redirect(call, permanent = true, "/caches/runescape/{id}/map.png") }
} }
}.start(wait = true) }.start(wait = true)
} }
private suspend fun redirect(call: ApplicationCall, permanent: Boolean, path: String) {
val destination = path.replace(PARAMETER) { match ->
val (name) = match.destructured
call.parameters[name] ?: throw IllegalArgumentException()
}
call.respondRedirect(destination, permanent)
}
private companion object {
private val PARAMETER = Regex("\\{([^}]*)}")
}
} }

@ -0,0 +1,176 @@
-- @formatter:off
CREATE TABLE scopes (
id SERIAL PRIMARY KEY NOT NULL,
name TEXT UNIQUE NOT NULL
);
INSERT INTO scopes (name) VALUES ('runescape');
ALTER TABLE games
ADD COLUMN scope_id INTEGER DEFAULT 1 NOT NULL REFERENCES scopes (id);
ALTER TABLE games
ALTER COLUMN scope_id DROP DEFAULT;
-- XXX(gpe): I don't think we can easily replace this as the source_groups
-- table doesn't contain a scope_id directly - only indirectly via the sources
-- and games tables.
ALTER TABLE source_groups
DROP CONSTRAINT source_groups_archive_id_group_id_version_version_truncate_fkey;
ALTER TABLE groups
ADD COLUMN scope_id INTEGER DEFAULT 1 NOT NULL REFERENCES scopes (id),
DROP CONSTRAINT groups_pkey,
ADD PRIMARY KEY (scope_id, archive_id, group_id, version, version_truncated, container_id);
ALTER TABLE groups
ALTER COLUMN scope_id DROP DEFAULT;
CREATE FUNCTION resolve_index(_scope_id INTEGER, _archive_id uint1, _crc32 INTEGER, _version INTEGER) RETURNS SETOF containers AS $$
SELECT c.*
FROM groups g
JOIN containers c ON c.id = g.container_id
JOIN indexes i ON i.container_id = c.id
WHERE g.scope_id = _scope_id AND g.archive_id = 255 AND g.group_id = _archive_id::INTEGER AND c.crc32 = _crc32 AND
g.version = _version AND NOT g.version_truncated AND i.version = _version
ORDER BY c.id ASC
LIMIT 1;
$$ LANGUAGE SQL STABLE PARALLEL SAFE ROWS 1;
CREATE FUNCTION resolve_group(_scope_id INTEGER, _archive_id uint1, _group_id INTEGER, _crc32 INTEGER, _version INTEGER) RETURNS SETOF containers AS $$
SELECT c.*
FROM groups g
JOIN containers c ON c.id = g.container_id
WHERE g.scope_id = _scope_id AND g.archive_id = _archive_id AND g.group_id = _group_id AND c.crc32 = _crc32 AND (
(g.version = _version AND NOT g.version_truncated) OR
(g.version = _version & 65535 AND g.version_truncated)
)
ORDER BY g.version_truncated ASC, c.id ASC
LIMIT 1;
$$ LANGUAGE SQL STABLE PARALLEL SAFE ROWS 1;
DROP VIEW resolved_groups;
DROP VIEW resolved_indexes;
CREATE VIEW resolved_indexes AS
SELECT s.id AS scope_id, m.id AS master_index_id, a.archive_id, c.data, c.id AS container_id
FROM scopes s
CROSS JOIN master_indexes m
JOIN master_index_archives a ON a.master_index_id = m.id
JOIN resolve_index(s.id, a.archive_id, a.crc32, a.version) c ON TRUE;
CREATE VIEW resolved_groups (scope_id, master_index_id, archive_id, group_id, name_hash, version, data, encrypted, empty_loc, key_id) AS
WITH i AS NOT MATERIALIZED (
SELECT scope_id, master_index_id, archive_id, data, container_id
FROM resolved_indexes
)
SELECT i.scope_id, i.master_index_id, 255::uint1, i.archive_id::INTEGER, NULL, NULL, i.data, FALSE, FALSE, NULL
FROM i
UNION ALL
SELECT i.scope_id, i.master_index_id, i.archive_id, ig.group_id, ig.name_hash, ig.version, c.data, c.encrypted, c.empty_loc, c.key_id
FROM i
JOIN index_groups ig ON ig.container_id = i.container_id
JOIN resolve_group(i.scope_id, i.archive_id, ig.group_id, ig.crc32, ig.version) c ON TRUE;
DROP VIEW colliding_groups;
CREATE VIEW colliding_groups (scope_id, archive_id, group_id, crc32, truncated_version, versions, containers) AS
SELECT
g.scope_id,
g.archive_id,
g.group_id,
c.crc32,
g.version & 65535 AS truncated_version,
array_agg(DISTINCT g.version ORDER BY g.version ASC),
array_agg(DISTINCT c.id ORDER BY c.id ASC)
FROM groups g
JOIN containers c ON c.id = g.container_id
GROUP BY g.scope_id, g.archive_id, g.group_id, c.crc32, truncated_version
HAVING COUNT(DISTINCT c.id) > 1;
DROP VIEW cache_stats;
DROP MATERIALIZED VIEW master_index_stats;
DROP MATERIALIZED VIEW index_stats;
CREATE MATERIALIZED VIEW index_stats (
scope_id,
archive_id,
container_id,
valid_groups,
groups,
valid_keys,
keys,
size,
blocks
) AS
SELECT
s.id AS scope_id,
g.group_id AS archive_id,
i.container_id,
COUNT(*) FILTER (WHERE c.id IS NOT NULL) AS valid_groups,
COUNT(*) AS groups,
COUNT(*) FILTER (WHERE c.encrypted AND (c.key_id IS NOT NULL OR c.empty_loc)) AS valid_keys,
COUNT(*) FILTER (WHERE c.encrypted) AS keys,
SUM(length(c.data) + 2) FILTER (WHERE c.id IS NOT NULL) AS size,
SUM(group_blocks(ig.group_id, length(c.data) + 2)) FILTER (WHERE c.id IS NOT NULL) AS blocks
FROM scopes s
CROSS JOIN indexes i
JOIN groups g ON g.container_id = i.container_id AND g.archive_id = 255 AND NOT g.version_truncated AND
g.version = i.version
JOIN index_groups ig ON ig.container_id = i.container_id
LEFT JOIN resolve_group(s.id, g.group_id::uint1, ig.group_id, ig.crc32, ig.version) c ON TRUE
GROUP BY s.id, g.group_id, i.container_id;
CREATE UNIQUE INDEX ON index_stats (scope_id, archive_id, container_id);
CREATE MATERIALIZED VIEW master_index_stats (
scope_id,
master_index_id,
valid_indexes,
indexes,
valid_groups,
groups,
valid_keys,
keys,
size,
blocks
) AS
SELECT
sc.id,
m.id,
COUNT(*) FILTER (WHERE c.id IS NOT NULL OR (a.version = 0 AND a.crc32 = 0)) AS valid_indexes,
COUNT(*) FILTER (WHERE a.master_index_id IS NOT NULL) AS indexes,
SUM(COALESCE(s.valid_groups, 0)) AS valid_groups,
SUM(COALESCE(s.groups, 0)) AS groups,
SUM(COALESCE(s.valid_keys, 0)) AS valid_keys,
SUM(COALESCE(s.keys, 0)) AS keys,
SUM(COALESCE(s.size, 0)) + SUM(COALESCE(length(c.data), 0)) AS size,
SUM(COALESCE(s.blocks, 0)) + SUM(COALESCE(group_blocks(a.archive_id, length(c.data)), 0)) AS blocks
FROM scopes sc
CROSS JOIN master_indexes m
LEFT JOIN master_index_archives a ON a.master_index_id = m.id
LEFT JOIN resolve_index(sc.id, a.archive_id, a.crc32, a.version) c ON TRUE
LEFT JOIN index_stats s ON s.scope_id = sc.id AND s.archive_id = a.archive_id AND s.container_id = c.id
GROUP BY sc.id, m.id;
CREATE UNIQUE INDEX ON master_index_stats (scope_id, master_index_id);
CREATE VIEW cache_stats AS
SELECT
s.id AS scope_id,
c.id AS cache_id,
COALESCE(ms.valid_indexes, cs.valid_archives) AS valid_indexes,
COALESCE(ms.indexes, cs.archives) AS indexes,
COALESCE(ms.valid_groups, cs.valid_files) AS valid_groups,
COALESCE(ms.groups, cs.files) AS groups,
COALESCE(ms.valid_keys, 0) AS valid_keys,
COALESCE(ms.keys, 0) AS keys,
COALESCE(ms.size, cs.size) AS size,
COALESCE(ms.blocks, cs.blocks) AS blocks
FROM scopes s
CROSS JOIN caches c
LEFT JOIN master_index_stats ms ON ms.scope_id = s.id AND ms.master_index_id = c.id
LEFT JOIN crc_table_stats cs ON s.name = 'runescape' AND cs.crc_table_id = c.id;
DROP FUNCTION resolve_group(_archive_id uint1, _group_id INTEGER, _crc32 INTEGER, _version INTEGER);
DROP FUNCTION resolve_index(_archive_id uint1, _crc32 INTEGER, _version INTEGER);

@ -0,0 +1,2 @@
-- @formatter:off
ALTER TABLE caches ADD COLUMN hidden BOOLEAN NOT NULL DEFAULT FALSE;

@ -0,0 +1,95 @@
-- @formatter:off
CREATE MATERIALIZED VIEW index_stats_new (
scope_id,
archive_id,
container_id,
valid_groups,
groups,
valid_keys,
keys,
size,
blocks
) AS
SELECT
s.id AS scope_id,
g.group_id AS archive_id,
i.container_id,
COUNT(*) FILTER (WHERE c.id IS NOT NULL) AS valid_groups,
COUNT(*) AS groups,
COUNT(*) FILTER (WHERE c.encrypted AND (c.key_id IS NOT NULL OR c.empty_loc)) AS valid_keys,
COUNT(*) FILTER (WHERE c.encrypted) AS keys,
SUM(length(c.data) + 2) FILTER (WHERE c.id IS NOT NULL) AS size,
SUM(group_blocks(ig.group_id, length(c.data) + 2)) FILTER (WHERE c.id IS NOT NULL) AS blocks
FROM scopes s
CROSS JOIN indexes i
JOIN groups g ON g.scope_id = s.id AND g.container_id = i.container_id AND g.archive_id = 255 AND
NOT g.version_truncated AND g.version = i.version
JOIN index_groups ig ON ig.container_id = i.container_id
LEFT JOIN resolve_group(s.id, g.group_id::uint1, ig.group_id, ig.crc32, ig.version) c ON TRUE
GROUP BY s.id, g.group_id, i.container_id;
CREATE UNIQUE INDEX ON index_stats_new (scope_id, archive_id, container_id);
ALTER MATERIALIZED VIEW index_stats RENAME TO index_stats_old;
ALTER INDEX index_stats_scope_id_archive_id_container_id_idx RENAME TO index_stats_old_scope_id_archive_id_container_id_idx;
ALTER MATERIALIZED VIEW index_stats_new RENAME TO index_stats;
ALTER INDEX index_stats_new_scope_id_archive_id_container_id_idx RENAME TO index_stats_scope_id_archive_id_container_id_idx;
CREATE MATERIALIZED VIEW master_index_stats_new (
scope_id,
master_index_id,
valid_indexes,
indexes,
valid_groups,
groups,
valid_keys,
keys,
size,
blocks
) AS
SELECT
sc.id,
m.id,
COUNT(*) FILTER (WHERE c.id IS NOT NULL OR (a.version = 0 AND a.crc32 = 0)) AS valid_indexes,
COUNT(*) FILTER (WHERE a.master_index_id IS NOT NULL) AS indexes,
SUM(COALESCE(s.valid_groups, 0)) AS valid_groups,
SUM(COALESCE(s.groups, 0)) AS groups,
SUM(COALESCE(s.valid_keys, 0)) AS valid_keys,
SUM(COALESCE(s.keys, 0)) AS keys,
SUM(COALESCE(s.size, 0)) + SUM(COALESCE(length(c.data), 0)) AS size,
SUM(COALESCE(s.blocks, 0)) + SUM(COALESCE(group_blocks(a.archive_id, length(c.data)), 0)) AS blocks
FROM scopes sc
CROSS JOIN master_indexes m
LEFT JOIN master_index_archives a ON a.master_index_id = m.id
LEFT JOIN resolve_index(sc.id, a.archive_id, a.crc32, a.version) c ON TRUE
LEFT JOIN index_stats s ON s.scope_id = sc.id AND s.archive_id = a.archive_id AND s.container_id = c.id
GROUP BY sc.id, m.id;
CREATE UNIQUE INDEX ON master_index_stats_new (scope_id, master_index_id);
ALTER MATERIALIZED VIEW master_index_stats RENAME TO master_index_stats_old;
ALTER INDEX master_index_stats_scope_id_master_index_id_idx RENAME TO master_index_stats_old_scope_id_master_index_id_idx;
ALTER MATERIALIZED VIEW master_index_stats_new RENAME TO master_index_stats;
ALTER INDEX master_index_stats_new_scope_id_master_index_id_idx RENAME TO master_index_stats_scope_id_master_index_id_idx;
CREATE OR REPLACE VIEW cache_stats AS
SELECT
s.id AS scope_id,
c.id AS cache_id,
COALESCE(ms.valid_indexes, cs.valid_archives) AS valid_indexes,
COALESCE(ms.indexes, cs.archives) AS indexes,
COALESCE(ms.valid_groups, cs.valid_files) AS valid_groups,
COALESCE(ms.groups, cs.files) AS groups,
COALESCE(ms.valid_keys, 0) AS valid_keys,
COALESCE(ms.keys, 0) AS keys,
COALESCE(ms.size, cs.size) AS size,
COALESCE(ms.blocks, cs.blocks) AS blocks
FROM scopes s
CROSS JOIN caches c
LEFT JOIN master_index_stats ms ON ms.scope_id = s.id AND ms.master_index_id = c.id
LEFT JOIN crc_table_stats cs ON s.name = 'runescape' AND cs.crc_table_id = c.id;
DROP MATERIALIZED VIEW master_index_stats_old;
DROP MATERIALIZED VIEW index_stats_old;

@ -0,0 +1,95 @@
-- @formatter:off
CREATE MATERIALIZED VIEW index_stats_new (
scope_id,
archive_id,
container_id,
valid_groups,
groups,
valid_keys,
keys,
size,
blocks
) AS
SELECT
s.id AS scope_id,
g.group_id AS archive_id,
i.container_id,
COUNT(*) FILTER (WHERE c.id IS NOT NULL) AS valid_groups,
COUNT(*) FILTER (WHERE ig.container_id IS NOT NULL) AS groups,
COUNT(*) FILTER (WHERE c.encrypted AND (c.key_id IS NOT NULL OR c.empty_loc)) AS valid_keys,
COUNT(*) FILTER (WHERE c.encrypted) AS keys,
SUM(length(c.data) + 2) FILTER (WHERE c.id IS NOT NULL) AS size,
SUM(group_blocks(ig.group_id, length(c.data) + 2)) FILTER (WHERE c.id IS NOT NULL) AS blocks
FROM scopes s
CROSS JOIN indexes i
JOIN groups g ON g.scope_id = s.id AND g.container_id = i.container_id AND g.archive_id = 255 AND
NOT g.version_truncated AND g.version = i.version
LEFT JOIN index_groups ig ON ig.container_id = i.container_id
LEFT JOIN resolve_group(s.id, g.group_id::uint1, ig.group_id, ig.crc32, ig.version) c ON TRUE
GROUP BY s.id, g.group_id, i.container_id;
CREATE UNIQUE INDEX ON index_stats_new (scope_id, archive_id, container_id);
ALTER MATERIALIZED VIEW index_stats RENAME TO index_stats_old;
ALTER INDEX index_stats_scope_id_archive_id_container_id_idx RENAME TO index_stats_old_scope_id_archive_id_container_id_idx;
ALTER MATERIALIZED VIEW index_stats_new RENAME TO index_stats;
ALTER INDEX index_stats_new_scope_id_archive_id_container_id_idx RENAME TO index_stats_scope_id_archive_id_container_id_idx;
CREATE MATERIALIZED VIEW master_index_stats_new (
scope_id,
master_index_id,
valid_indexes,
indexes,
valid_groups,
groups,
valid_keys,
keys,
size,
blocks
) AS
SELECT
sc.id,
m.id,
COUNT(*) FILTER (WHERE c.id IS NOT NULL OR (a.version = 0 AND a.crc32 = 0)) AS valid_indexes,
COUNT(*) FILTER (WHERE a.master_index_id IS NOT NULL) AS indexes,
SUM(COALESCE(s.valid_groups, 0)) AS valid_groups,
SUM(COALESCE(s.groups, 0)) AS groups,
SUM(COALESCE(s.valid_keys, 0)) AS valid_keys,
SUM(COALESCE(s.keys, 0)) AS keys,
SUM(COALESCE(s.size, 0)) + SUM(COALESCE(length(c.data), 0)) AS size,
SUM(COALESCE(s.blocks, 0)) + SUM(COALESCE(group_blocks(a.archive_id, length(c.data)), 0)) AS blocks
FROM scopes sc
CROSS JOIN master_indexes m
LEFT JOIN master_index_archives a ON a.master_index_id = m.id
LEFT JOIN resolve_index(sc.id, a.archive_id, a.crc32, a.version) c ON TRUE
LEFT JOIN index_stats s ON s.scope_id = sc.id AND s.archive_id = a.archive_id AND s.container_id = c.id
GROUP BY sc.id, m.id;
CREATE UNIQUE INDEX ON master_index_stats_new (scope_id, master_index_id);
ALTER MATERIALIZED VIEW master_index_stats RENAME TO master_index_stats_old;
ALTER INDEX master_index_stats_scope_id_master_index_id_idx RENAME TO master_index_stats_old_scope_id_master_index_id_idx;
ALTER MATERIALIZED VIEW master_index_stats_new RENAME TO master_index_stats;
ALTER INDEX master_index_stats_new_scope_id_master_index_id_idx RENAME TO master_index_stats_scope_id_master_index_id_idx;
CREATE OR REPLACE VIEW cache_stats AS
SELECT
s.id AS scope_id,
c.id AS cache_id,
COALESCE(ms.valid_indexes, cs.valid_archives) AS valid_indexes,
COALESCE(ms.indexes, cs.archives) AS indexes,
COALESCE(ms.valid_groups, cs.valid_files) AS valid_groups,
COALESCE(ms.groups, cs.files) AS groups,
COALESCE(ms.valid_keys, 0) AS valid_keys,
COALESCE(ms.keys, 0) AS keys,
COALESCE(ms.size, cs.size) AS size,
COALESCE(ms.blocks, cs.blocks) AS blocks
FROM scopes s
CROSS JOIN caches c
LEFT JOIN master_index_stats ms ON ms.scope_id = s.id AND ms.master_index_id = c.id
LEFT JOIN crc_table_stats cs ON s.name = 'runescape' AND cs.crc_table_id = c.id;
DROP MATERIALIZED VIEW master_index_stats_old;
DROP MATERIALIZED VIEW index_stats_old;

@ -0,0 +1,95 @@
-- @formatter:off
CREATE MATERIALIZED VIEW index_stats_new (
scope_id,
archive_id,
container_id,
valid_groups,
groups,
valid_keys,
keys,
size,
blocks
) AS
SELECT
s.id AS scope_id,
g.group_id AS archive_id,
i.container_id,
COUNT(*) FILTER (WHERE c.id IS NOT NULL) AS valid_groups,
COUNT(*) FILTER (WHERE ig.container_id IS NOT NULL) AS groups,
COUNT(*) FILTER (WHERE c.encrypted AND (c.key_id IS NOT NULL OR c.empty_loc)) AS valid_keys,
COUNT(*) FILTER (WHERE c.encrypted) AS keys,
COALESCE(SUM(length(c.data) + 2) FILTER (WHERE c.id IS NOT NULL), 0) AS size,
COALESCE(SUM(group_blocks(ig.group_id, length(c.data) + 2)) FILTER (WHERE c.id IS NOT NULL), 0) AS blocks
FROM scopes s
CROSS JOIN indexes i
JOIN groups g ON g.scope_id = s.id AND g.container_id = i.container_id AND g.archive_id = 255 AND
NOT g.version_truncated AND g.version = i.version
LEFT JOIN index_groups ig ON ig.container_id = i.container_id
LEFT JOIN resolve_group(s.id, g.group_id::uint1, ig.group_id, ig.crc32, ig.version) c ON TRUE
GROUP BY s.id, g.group_id, i.container_id;
CREATE UNIQUE INDEX ON index_stats_new (scope_id, archive_id, container_id);
ALTER MATERIALIZED VIEW index_stats RENAME TO index_stats_old;
ALTER INDEX index_stats_scope_id_archive_id_container_id_idx RENAME TO index_stats_old_scope_id_archive_id_container_id_idx;
ALTER MATERIALIZED VIEW index_stats_new RENAME TO index_stats;
ALTER INDEX index_stats_new_scope_id_archive_id_container_id_idx RENAME TO index_stats_scope_id_archive_id_container_id_idx;
CREATE MATERIALIZED VIEW master_index_stats_new (
scope_id,
master_index_id,
valid_indexes,
indexes,
valid_groups,
groups,
valid_keys,
keys,
size,
blocks
) AS
SELECT
sc.id,
m.id,
COUNT(*) FILTER (WHERE c.id IS NOT NULL OR (a.version = 0 AND a.crc32 = 0)) AS valid_indexes,
COUNT(*) FILTER (WHERE a.master_index_id IS NOT NULL) AS indexes,
SUM(COALESCE(s.valid_groups, 0)) AS valid_groups,
SUM(COALESCE(s.groups, 0)) AS groups,
SUM(COALESCE(s.valid_keys, 0)) AS valid_keys,
SUM(COALESCE(s.keys, 0)) AS keys,
SUM(COALESCE(s.size, 0)) + SUM(COALESCE(length(c.data), 0)) AS size,
SUM(COALESCE(s.blocks, 0)) + SUM(COALESCE(group_blocks(a.archive_id, length(c.data)), 0)) AS blocks
FROM scopes sc
CROSS JOIN master_indexes m
LEFT JOIN master_index_archives a ON a.master_index_id = m.id
LEFT JOIN resolve_index(sc.id, a.archive_id, a.crc32, a.version) c ON TRUE
LEFT JOIN index_stats s ON s.scope_id = sc.id AND s.archive_id = a.archive_id AND s.container_id = c.id
GROUP BY sc.id, m.id;
CREATE UNIQUE INDEX ON master_index_stats_new (scope_id, master_index_id);
ALTER MATERIALIZED VIEW master_index_stats RENAME TO master_index_stats_old;
ALTER INDEX master_index_stats_scope_id_master_index_id_idx RENAME TO master_index_stats_old_scope_id_master_index_id_idx;
ALTER MATERIALIZED VIEW master_index_stats_new RENAME TO master_index_stats;
ALTER INDEX master_index_stats_new_scope_id_master_index_id_idx RENAME TO master_index_stats_scope_id_master_index_id_idx;
CREATE OR REPLACE VIEW cache_stats AS
SELECT
s.id AS scope_id,
c.id AS cache_id,
COALESCE(ms.valid_indexes, cs.valid_archives) AS valid_indexes,
COALESCE(ms.indexes, cs.archives) AS indexes,
COALESCE(ms.valid_groups, cs.valid_files) AS valid_groups,
COALESCE(ms.groups, cs.files) AS groups,
COALESCE(ms.valid_keys, 0) AS valid_keys,
COALESCE(ms.keys, 0) AS keys,
COALESCE(ms.size, cs.size) AS size,
COALESCE(ms.blocks, cs.blocks) AS blocks
FROM scopes s
CROSS JOIN caches c
LEFT JOIN master_index_stats ms ON ms.scope_id = s.id AND ms.master_index_id = c.id
LEFT JOIN crc_table_stats cs ON s.name = 'runescape' AND cs.crc_table_id = c.id;
DROP MATERIALIZED VIEW master_index_stats_old;
DROP MATERIALIZED VIEW index_stats_old;

@ -0,0 +1,53 @@
-- @formatter:off
DROP VIEW cache_stats;
DROP MATERIALIZED VIEW crc_table_stats;
DROP MATERIALIZED VIEW version_list_stats;
CREATE MATERIALIZED VIEW version_list_stats AS
SELECT
v.blob_id,
vf.index_id,
COUNT(*) FILTER (WHERE b.id IS NOT NULL) AS valid_files,
COUNT(*) AS files,
SUM(length(b.data) + 2) FILTER (WHERE b.id IS NOT NULL) AS size,
SUM(group_blocks(vf.file_id, length(b.data) + 2)) AS blocks
FROM version_lists v
JOIN version_list_files vf ON vf.blob_id = v.blob_id
LEFT JOIN resolve_file(vf.index_id, vf.file_id, vf.version, vf.crc32) b ON TRUE
GROUP BY v.blob_id, vf.index_id;
CREATE UNIQUE INDEX ON version_list_stats (blob_id, index_id);
CREATE MATERIALIZED VIEW crc_table_stats AS
SELECT
c.id AS crc_table_id,
COUNT(*) FILTER (WHERE b.id IS NOT NULL AND a.crc32 <> 0) AS valid_archives,
COUNT(*) FILTER (WHERE a.crc32 <> 0) AS archives,
SUM(COALESCE(s.valid_files, 0)) AS valid_files,
SUM(COALESCE(s.files, 0)) AS files,
SUM(COALESCE(s.size, 0)) + SUM(COALESCE(length(b.data), 0)) AS size,
SUM(COALESCE(s.blocks, 0)) + SUM(COALESCE(group_blocks(a.archive_id, length(b.data)), 0)) AS blocks
FROM crc_tables c
LEFT JOIN crc_table_archives a ON a.crc_table_id = c.id
LEFT JOIN resolve_archive(a.archive_id, a.crc32) b ON TRUE
LEFT JOIN version_list_stats s ON s.blob_id = b.id
GROUP BY c.id;
CREATE UNIQUE INDEX ON crc_table_stats (crc_table_id);
CREATE VIEW cache_stats AS
SELECT
s.id AS scope_id,
c.id AS cache_id,
COALESCE(ms.valid_indexes, cs.valid_archives) AS valid_indexes,
COALESCE(ms.indexes, cs.archives) AS indexes,
COALESCE(ms.valid_groups, cs.valid_files) AS valid_groups,
COALESCE(ms.groups, cs.files) AS groups,
COALESCE(ms.valid_keys, 0) AS valid_keys,
COALESCE(ms.keys, 0) AS keys,
COALESCE(ms.size, cs.size) AS size,
COALESCE(ms.blocks, cs.blocks) AS blocks
FROM scopes s
CROSS JOIN caches c
LEFT JOIN master_index_stats ms ON ms.scope_id = s.id AND ms.master_index_id = c.id
LEFT JOIN crc_table_stats cs ON s.name = 'runescape' AND cs.crc_table_id = c.id;

@ -0,0 +1,2 @@
-- @formatter:off
ALTER TYPE key_source ADD VALUE 'hdos';

@ -0,0 +1,3 @@
-- @formatter:off
ALTER TYPE source_type ADD VALUE 'cross_pollination';

@ -0,0 +1,7 @@
-- @formatter:off
ALTER TABLE sources
ALTER COLUMN cache_id DROP NOT NULL,
ALTER COLUMN game_id DROP NOT NULL;
CREATE UNIQUE INDEX ON sources (type) WHERE type = 'cross_pollination';

@ -1,3 +1,4 @@
-- @formatter:off
ALTER TABLE games ALTER TABLE games
DROP COLUMN hostname, DROP COLUMN hostname,
DROP COLUMN port, DROP COLUMN port,

@ -1,2 +1,3 @@
-- @formatter:off
ALTER TABLE games ALTER TABLE games
DROP COLUMN key; DROP COLUMN key;

@ -0,0 +1,43 @@
var buildRegex = new RegExp('>([0-9]+)(?:[.]([0-9]+))?<');
function customSort(name, order, data) {
order = order === 'asc' ? 1 : -1;
data.sort(function (a, b) {
a = a[name];
b = b[name];
if (!a) {
return 1;
} else if (!b) {
return -1;
}
if (name === 'builds') {
return buildSort(a, b) * order;
} else {
if (a < b) {
return -order;
} else if (a === b) {
return 0;
} else {
return order;
}
}
});
}
function buildSort(a, b) {
a = buildRegex.exec(a);
b = buildRegex.exec(b);
var aMajor = parseInt(a[1]);
var bMajor = parseInt(b[1]);
if (aMajor !== bMajor) {
return aMajor - bMajor;
}
var aMinor = a[2] ? parseInt(a[2]) : 0;
var bMinor = b[2] ? parseInt(b[2]) : 0;
return aMinor - bMinor;
}

@ -0,0 +1,285 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head th:replace="layout.html :: head">
<title>API - OpenRS2 Archive</title>
<link rel="stylesheet" href="/webjars/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="/static/css/openrs2.css" />
<script src="/webjars/jquery/jquery.min.js" defer></script>
<script src="/webjars/bootstrap/js/bootstrap.bundle.min.js" defer></script>
</head>
<body>
<nav th:replace="layout.html :: nav"></nav>
<main class="container">
<h1>API</h1>
<p>All endpoints accept requests from any origin. Range requests are not supported by any endpoint.</p>
<h2><code>GET /caches.json</code></h2>
<p>
Returns a list of all caches, including all data available on the main <a href="/caches">caches</a>
page, in JSON format:
</p>
<pre><code>[
{
// The cache's internal ID.
"id": 1,
// A scope is a group of related games. Missing groups are only located
// from caches for games in the same scope.
//
// Currently the "runescape" scope is used for the "runescape" and
// "oldschool" games. Each FunOrb game has its own scope.
//
// Your code must be prepared for new scopes to be added in the future.
"scope": "runescape",
// The game's name. Your code must be prepared for new games to be
// added in the future.
"game": "runescape",
// Currently either "live" or "beta", but your code must be prepared
// for new environments to be added in the future.
"environment": "live",
// The language's ISO-639-1 code. Currently either "en", "de", "fr" or
// "pt", but your code must be prepared for new languages to be added
// in the future.
"language": "en",
// A list of build numbers the cache is associated with, which may be
// empty if the build number(s) are not known.
"builds": [
{
// The major number is always set.
"major": 549,
// The minor number may be null.
"minor": null
},
{
"major": 550,
"minor": null
}
],
// The earliest timestamp the cache was available to users, in ISO 8601
// format. May be null if not known.
"timestamp": "2009-06-12T14:55:58Z",
// A list of users who provided a copy of this cache.
//
// May be empty if the users wished to remain anonymous.
//
// The value "Jagex" indicates the cache was directly downloaded from
// Jagex's servers by the OpenRS2 project, so we are completely certain
// it is genuine. This value will never be used for a cache obtained
// from a third party.
"sources": [
"Erand",
"Hlwys",
"Jagex",
"K4rn4ge",
"Nathan",
"Rune-Wars"
],
// In old engine caches, the number of valid .jag archives that are not
// missing.
//
// In new engine caches, the number of valid JS5 indexes that are not
// missing.
//
// May be null if the cache is still being processed.
"valid_indexes": 29,
// In old engine caches, the total number of .jag archives that should
// exist, based on the cache's CRC table.
//
// In new engine caches, the total number of JS5 indexes that should
// exist, based on the JS5 master index.
//
// May be null if the cache is still being processed.
"indexes": 29,
// The number of valid files (old engine) or valid groups (new engine)
// that are not missing. May be null if the cache is still being processed.
"valid_groups": 71002,
// In old engine caches, the total number of files that should exist,
// based on the cache's versionlist.jag archive.
//
// In new engine caches, the total number of groups that should exist,
// based on the JS5 indexes that are available.
//
// May be null if the cache is still being processed.
"groups": 71146,
// The number of encrypted groups for which a valid key is available.
// May be null if the cache is still being processed.
"valid_keys": 1203,
// The total number of encrypted groups in the cache. May be null if
// the cache is still being processed.
"keys": 1240,
// The total size of all groups in the cache in bytes. May be null if
// the cache is still being processed.
"size": 74970573,
// The number of 520-byte blocks required to store the cache's data in
// a .dat2 file. May be null if the cache is still being processed.
"blocks": 185273,
// A boolean flag indicating if the cache is small enough to be
// downloaded in .dat2/.idx format. May be null if the cache is still
// being processed.
"disk_store_valid": true
},
...
]</code></pre>
<h2><code>GET /caches/&lt;scope&gt;/&lt;id&gt;/disk.zip</code></h2>
<p>
Returns a cache as a ZIP archive of <code>.dat/.idx</code>
(old engine) or <code>.dat2/.idx</code> (new engine) files. All
files are stored underneath a <code>cache</code> subdirectory
in the zip archive.
</p>
<h2><code>GET /caches/&lt;scope&gt;/&lt;id&gt;/flat-file.tar.gz</code></h2>
<p>
Returns a cache as a gzipped tarball of files, where each
file in the tarball holds a single file from the cache (old
engine) or single group (new engine).
</p>
<p>
The paths within the archive all have a format of
<code>cache/&lt;index&gt;/&lt;file&gt;.dat</code> (old engine)
or <code>cache/&lt;archive&gt;/&lt;group&gt;.dat</code> (new
engine).
</p>
<p>The two byte version trailers are included.</p>
<h2><code>GET /caches/&lt;scope&gt;/&lt;id&gt;/keys.json</code></h2>
<p>Returns a list of valid XTEA keys for the cache in JSON format:</p>
<pre><code>[
{
// The ID of the archive containing the group the key is used for.
// Typically this is 5 (maps), but do note that RuneScape 3 does
// support encrypting interfaces, though the functionality has not yet
// been used, and some FunOrb games also have encrypted groups.
"archive": 5,
// The ID of the group the key is used for.
"group": 1,
// The group's name hash, or null if the group has no name.
"name_hash": -1153472937,
// The name of the group, if available, or null if the group has no
// name or if the name is not known.
"name": "l40_55",
// The ID of the map square, if the group is an encrypted loc group
// (has a name of lX_Z). The map square ID is ((X &lt;&lt; 8) | Z).
// null if the group is not an encrypted loc group.
"mapsquare": 10295,
// The XTEA key, represented as four 32-bit integers.
"key": [
-1920480496,
-1423914110,
951774544,
-1419269290
]
},
...
]</code></pre>
<h2><code>GET /caches/&lt;scope&gt;/&lt;id&gt;/keys.zip</code></h2>
<p>
Returns a zip archive file of valid XTEA keys for loc groups.
Each key is stored in a text file containing four lines, with
each line containing a 32-bit component of the key as a decimal
string. The paths within the archive all have a format of
<code>keys/&lt;mapsquare&gt;.txt</code>.
</p>
<h2><code>GET /caches/&lt;scope&gt;/&lt;id&gt;/map.png</code></h2>
<p>
Renders the map squares in the cache, with a coloured outline
representing whether we have a valid key for each map square or
not:
</p>
<ul>
<li><strong>Valid key:</strong> green outline.</li>
<li><strong>Loc group is not encrypted:</strong> green outline.</li>
<li><strong>Empty loc group:</strong> grey outline.</li>
<li><strong>Key unknown:</strong> red outline.</li>
</ul>
<p>
Empty loc groups may be replaced with an unencrypted equivalent
with a cache editor.
</p>
<h2><code>GET /caches/&lt;scope&gt;/&lt;id&gt;/archives/&lt;archive&gt;/groups/&lt;group&gt;.dat</code></h2>
<p>
Returns a single file (old engine) or group (new engine) in
binary format. The response contains a <code>.jag</code>
archive (index 0 of an old engine cache), a GZIP-compressed
file (the remaining indexes of an old engine cache) or
JS5-compressed data (new engine cache, also known as a
container). The two byte version trailer is not included.
</p>
<h2><code>GET /keys/all.json</code></h2>
<p>
Returns a list of all XTEA keys in the database, including
candidate keys that have not been validated against any cache.
</p>
<pre><code>[
// The XTEA key, represented as four 32-bit integers.
[
-2147135705,
1113423446,
1294100345,
946019601
],
...
]</code></pre>
<h2><code>GET /keys/valid.json</code></h2>
<p>
Returns a list of XTEA keys in the database, only including
keys validated against at least one cache.
</p>
<pre><code>[
// The XTEA key, represented as four 32-bit integers.
[
-2147135705,
1113423446,
1294100345,
946019601
],
...
]</code></pre>
</main>
</body>
</html>

@ -12,7 +12,7 @@
<main class="container"> <main class="container">
<h1>Caches</h1> <h1>Caches</h1>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped table-bordered table-hover" data-toggle="table" data-filter-control="true" data-sticky-header="true"> <table class="table table-striped table-bordered table-hover" data-toggle="table" data-filter-control="true" data-sticky-header="true" data-custom-sort="customSort">
<thead class="table-dark"> <thead class="table-dark">
<tr> <tr>
<th data-field="game" data-filter-control="select">Game</th> <th data-field="game" data-filter-control="select">Game</th>
@ -34,20 +34,22 @@
<td th:text="${cache.game}">runescape</td> <td th:text="${cache.game}">runescape</td>
<td th:text="${cache.environment}">live</td> <td th:text="${cache.environment}">live</td>
<td th:text="${cache.language}">en</td> <td th:text="${cache.language}">en</td>
<td class="text-right"> <td class="text-end">
<span th:each="build, it : ${cache.builds}" th:remove="tag"> <span th:each="build, it : ${cache.builds}" th:remove="tag">
<span th:text="${build}">550</span> <span th:text="${build}">550</span>
<br th:remove="${it.last}? 'all' : 'none'" /> <br th:remove="${it.last}? 'all' : 'none'" />
</span> </span>
</td> </td>
<td> <td>
<span th:if="${cache.timestamp}" th:remove="tag">
<span th:text="${#temporals.format(cache.timestamp, 'yyyy-MM-dd')}"></span> <span th:text="${#temporals.format(cache.timestamp, 'yyyy-MM-dd')}"></span>
<br /> <br />
<span th:text="${#temporals.format(cache.timestamp, 'HH:mm:ss')}"></span> <span th:text="${#temporals.format(cache.timestamp, 'HH:mm:ss')}"></span>
</span>
</td> </td>
<td th:text="${#strings.setJoin(cache.sources, ', ')}"></td> <td th:text="${#strings.setJoin(cache.sources, ', ')}"></td>
<td th:classappend="${cache.stats}? (${cache.stats.allIndexesValid}? 'table-success' : 'table-danger')" <td th:classappend="${cache.stats}? (${cache.stats.allIndexesValid}? 'table-success' : 'table-danger')"
class="text-right"> class="text-end">
<span <span
th:text="${cache.stats}? ${cache.stats.validIndexes} + '&nbsp;/&nbsp;' + ${cache.stats.indexes} : 'Calculating...'"></span> th:text="${cache.stats}? ${cache.stats.validIndexes} + '&nbsp;/&nbsp;' + ${cache.stats.indexes} : 'Calculating...'"></span>
<br /> <br />
@ -55,7 +57,7 @@
th:text="${cache.stats}? '(' + ${#numbers.formatPercent(cache.stats.validIndexesFraction, 1, 2)} + ')'"></span> th:text="${cache.stats}? '(' + ${#numbers.formatPercent(cache.stats.validIndexesFraction, 1, 2)} + ')'"></span>
</td> </td>
<td th:classappend="${cache.stats}? (${cache.stats.allGroupsValid}? 'table-success' : 'table-warning')" <td th:classappend="${cache.stats}? (${cache.stats.allGroupsValid}? 'table-success' : 'table-warning')"
class="text-right"> class="text-end">
<span <span
th:text="${cache.stats}? ${#numbers.formatInteger(cache.stats.validGroups, 1, 'COMMA')} + '&nbsp;/&nbsp;' + ${#numbers.formatInteger(cache.stats.groups, 1, 'COMMA')} : 'Calculating...'"></span> th:text="${cache.stats}? ${#numbers.formatInteger(cache.stats.validGroups, 1, 'COMMA')} + '&nbsp;/&nbsp;' + ${#numbers.formatInteger(cache.stats.groups, 1, 'COMMA')} : 'Calculating...'"></span>
<br /> <br />
@ -63,7 +65,7 @@
th:text="${cache.stats}? '(' + ${#numbers.formatPercent(cache.stats.validGroupsFraction, 1, 2)} + ')'"></span> th:text="${cache.stats}? '(' + ${#numbers.formatPercent(cache.stats.validGroupsFraction, 1, 2)} + ')'"></span>
</td> </td>
<td th:classappend="${cache.stats}? (${cache.stats.allKeysValid}? 'table-success' : 'table-warning')" <td th:classappend="${cache.stats}? (${cache.stats.allKeysValid}? 'table-success' : 'table-warning')"
class="text-right"> class="text-end">
<span <span
th:text="${cache.stats}? ${#numbers.formatInteger(cache.stats.validKeys, 1, 'COMMA')} + '&nbsp;/&nbsp;' + ${#numbers.formatInteger(cache.stats.keys, 1, 'COMMA')} : 'Calculating...'"></span> th:text="${cache.stats}? ${#numbers.formatInteger(cache.stats.validKeys, 1, 'COMMA')} + '&nbsp;/&nbsp;' + ${#numbers.formatInteger(cache.stats.keys, 1, 'COMMA')} : 'Calculating...'"></span>
<br /> <br />
@ -72,7 +74,7 @@
</td> </td>
<!--/*@thymesVar id="#byteunits" type="org.openrs2.archive.web.ByteUnits"*/--> <!--/*@thymesVar id="#byteunits" type="org.openrs2.archive.web.ByteUnits"*/-->
<td th:text="${cache.stats}? ${#byteunits.format(cache.stats.size)} : 'Calculating...'" <td th:text="${cache.stats}? ${#byteunits.format(cache.stats.size)} : 'Calculating...'"
class="text-right">Calculating... class="text-end">Calculating...
</td> </td>
<td> <td>
<div class="btn-group"> <div class="btn-group">
@ -84,25 +86,25 @@
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li th:if="${cache.stats != null and cache.stats.diskStoreValid}"><a <li th:if="${cache.stats != null and cache.stats.diskStoreValid}"><a
th:href="${'/caches/' + cache.id + '/disk.zip'}" th:href="${'/caches/' + cache.scope + '/' + cache.id + '/disk.zip'}"
class="dropdown-item">Cache (.dat2/.idx)</a></li> class="dropdown-item">Cache (.dat2/.idx)</a></li>
<li><a th:href="${'/caches/' + cache.id + '/flat-file.tar.gz'}" <li><a th:href="${'/caches/' + cache.scope + '/' + cache.id + '/flat-file.tar.gz'}"
class="dropdown-item">Cache (Flat file)</a></li> class="dropdown-item">Cache (Flat file)</a></li>
<li> <li>
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
</li> </li>
<li><a th:href="${'/caches/' + cache.id + '/keys.json'}" <li><a th:href="${'/caches/' + cache.scope + '/' + cache.id + '/keys.json'}"
class="dropdown-item">Keys (JSON)</a></li> class="dropdown-item">Keys (JSON)</a></li>
<li><a th:href="${'/caches/' + cache.id + '/keys.zip'}" <li><a th:href="${'/caches/' + cache.scope + '/' + cache.id + '/keys.zip'}"
class="dropdown-item">Keys (Text)</a></li> class="dropdown-item">Keys (Text)</a></li>
<li> <li>
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
</li> </li>
<li><a th:href="${'/caches/' + cache.id + '/map.png'}" <li><a th:href="${'/caches/' + cache.scope + '/' + cache.id + '/map.png'}"
class="dropdown-item">Map</a></li> class="dropdown-item">Map</a></li>
</ul> </ul>
</div> </div>
<a th:href="${'/caches/' + cache.id}" <a th:href="${'/caches/' + cache.scope + '/' + cache.id}"
class="btn btn-secondary btn-sm">More</a> class="btn btn-secondary btn-sm">More</a>
</div> </div>
</td> </td>
@ -110,6 +112,10 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<p>
The total size of all caches in the archive is
<strong th:text="${#byteunits.format(totalSize)}">0 B</strong>.
</p>
<p> <p>
<sup id="empty-locs">1</sup> Map squares in the middle of the <sup id="empty-locs">1</sup> Map squares in the middle of the
sea are unreachable by normal players, making it impossible to sea are unreachable by normal players, making it impossible to

@ -52,19 +52,19 @@
<div class="btn-toolbar"> <div class="btn-toolbar">
<div class="btn-group me-2"> <div class="btn-group me-2">
<a th:if="${cache.stats != null and cache.stats.diskStoreValid}" <a th:if="${cache.stats != null and cache.stats.diskStoreValid}"
th:href="${'/caches/' + cache.id + '/disk.zip'}" th:href="${'/caches/' + scope + '/' + cache.id + '/disk.zip'}"
class="btn btn-primary btn-sm">Cache (.dat2/.idx)</a> class="btn btn-primary btn-sm">Cache (.dat2/.idx)</a>
<a th:href="${'/caches/' + cache.id + '/flat-file.tar.gz'}" <a th:href="${'/caches/' + scope + '/' + cache.id + '/flat-file.tar.gz'}"
class="btn btn-primary btn-sm">Cache (Flat file)</a> class="btn btn-primary btn-sm">Cache (Flat file)</a>
</div> </div>
<div class="btn-group me-2"> <div class="btn-group me-2">
<a th:href="${'/caches/' + cache.id + '/keys.json'}" <a th:href="${'/caches/' + scope + '/' + cache.id + '/keys.json'}"
class="btn btn-primary btn-sm">Keys (JSON)</a> class="btn btn-primary btn-sm">Keys (JSON)</a>
<a th:href="${'/caches/' + cache.id + '/keys.zip'}" <a th:href="${'/caches/' + scope + '/' + cache.id + '/keys.zip'}"
class="btn btn-primary btn-sm">Keys (Text)</a> class="btn btn-primary btn-sm">Keys (Text)</a>
</div> </div>
<div class="btn-group"> <div class="btn-group">
<a th:href="${'/caches/' + cache.id + '/map.png'}" <a th:href="${'/caches/' + scope + '/' + cache.id + '/map.png'}"
class="btn btn-primary btn-sm">Map</a> class="btn btn-primary btn-sm">Map</a>
</div> </div>
</div> </div>
@ -102,7 +102,7 @@
<td th:text="${source.game}">runescape</td> <td th:text="${source.game}">runescape</td>
<td th:text="${source.environment}">live</td> <td th:text="${source.environment}">live</td>
<td th:text="${source.language}">en</td> <td th:text="${source.language}">en</td>
<td th:text="${source.build}" class="text-right">550</td> <td th:text="${source.build}" class="text-end">550</td>
<td th:text="${#temporals.format(source.timestamp, 'yyyy-MM-dd HH:mm:ss')}"></td> <td th:text="${#temporals.format(source.timestamp, 'yyyy-MM-dd HH:mm:ss')}"></td>
<td th:text="${source.name}"></td> <td th:text="${source.name}"></td>
<td th:text="${source.description}"></td> <td th:text="${source.description}"></td>
@ -124,37 +124,43 @@
<th>Archive</th> <th>Archive</th>
<th>Version</th> <th>Version</th>
<th>Checksum</th> <th>Checksum</th>
<th>Digest</th>
<th>Groups</th> <th>Groups</th>
<th>Total uncompressed length</th> <th>Keys<sup><a href="/caches#empty-locs">1</a></sup></th>
<th>Size<sup><a href="/caches#size">2</a></sup></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr th:each="entry, it : ${cache.masterIndex.entries}"> <tr th:each="entry, it : ${cache.masterIndex.entries}" th:with="archive=${cache.archives[it.index]}">
<td th:text="${it.index}" class="text-right">0</td> <td th:text="${it.index}" class="text-end">0</td>
<td th:text="${#numbers.formatInteger(entry.version, 1, 'COMMA')}" class="text-right">0</td> <td th:text="${#numbers.formatInteger(entry.version, 1, 'COMMA')}" class="text-end">0</td>
<td class="text-right"> <td class="text-end">
<code th:text="${entry.checksum}">0</code> <code th:text="${entry.checksum}">0</code>
</td> </td>
<td> <div th:switch="true" th:remove="tag">
<code <div th:case="${archive.stats != null}" th:remove="tag">
th:if="${cache.masterIndex.format >= @org.openrs2.cache.MasterIndexFormat@DIGESTS}"><span <td th:classappend="${archive.stats.allGroupsValid}? 'table-success' : 'table-warning'" class="text-end">
th:remove="tag" <span th:text="${#numbers.formatInteger(archive.stats.validGroups, 1, 'COMMA')} + '&nbsp;/&nbsp;' + ${#numbers.formatInteger(archive.stats.groups, 1, 'COMMA')}"></span>
th:text="${@io.netty.buffer.ByteBufUtil@hexDump(entry.digest).substring(0, 64)}"></span>&ZeroWidthSpace;<span <br />
th:remove="tag" <span th:text="'(' + ${#numbers.formatPercent(archive.stats.validGroupsFraction, 1, 2)} + ')'"></span>
th:text="${@io.netty.buffer.ByteBufUtil@hexDump(entry.digest).substring(64)}"></span></code>
</td> </td>
<td class="text-right"> <td th:classappend="${archive.stats.allKeysValid}? 'table-success' : 'table-warning'" class="text-end">
<span <span th:text="${#numbers.formatInteger(archive.stats.validKeys, 1, 'COMMA')} + '&nbsp;/&nbsp;' + ${#numbers.formatInteger(archive.stats.keys, 1, 'COMMA')}"></span>
th:if="${cache.masterIndex.format >= @org.openrs2.cache.MasterIndexFormat@LENGTHS}" <br />
th:text="${#numbers.formatInteger(entry.groups, 1, 'COMMA')}"></span> <span th:text="'(' + ${#numbers.formatPercent(archive.stats.validKeysFraction, 1, 2)} + ')'"></span>
</td> </td>
<td class="text-right">
<!--/*@thymesVar id="#byteunits" type="org.openrs2.archive.web.ByteUnits"*/--> <!--/*@thymesVar id="#byteunits" type="org.openrs2.archive.web.ByteUnits"*/-->
<span <td th:text="${#byteunits.format(archive.stats.size)}" class="text-end">0 B</td>
th:if="${cache.masterIndex.format >= @org.openrs2.cache.MasterIndexFormat@LENGTHS}" </div>
th:text="${#byteunits.format(@java.lang.Integer@toUnsignedLong(entry.totalUncompressedLength))}"></span> <div th:case="${archive.resolved}" th:remove="tag">
</td> <td class="text-center" colspan="3">Calculating...</td>
</div>
<div th:case="${entry.checksum != 0 || entry.version != 0}" th:remove="tag">
<td class="text-center table-danger" colspan="3">Index missing</td>
</div>
<div th:case="true" th:remove="tag">
<td class="text-center text-muted" colspan="3">N/A</td>
</div>
</div>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -170,18 +176,54 @@
<tr> <tr>
<th>Archive</th> <th>Archive</th>
<th>Checksum</th> <th>Checksum</th>
<th>Size<sup><a href="/caches#size">2</a></sup></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr th:each="entry, it : ${cache.checksumTable.entries}"> <tr th:each="entry, it : ${cache.checksumTable.entries}" th:with="archive=${cache.archives[it.index]}">
<td th:text="${it.index}" class="text-right">0</td> <td th:text="${it.index}" class="text-end">0</td>
<td class="text-right"> <td class="text-end">
<code th:text="${entry}">0</code> <code th:text="${entry}">0</code>
</td> </td>
<div th:switch="true" th:remove="tag">
<!--/*@thymesVar id="#byteunits" type="org.openrs2.archive.web.ByteUnits"*/-->
<td th:case="${archive.stats != null}" th:text="${#byteunits.format(archive.stats.size)}" class="text-end">0 B</td>
<td th:case="${archive.resolved}" class="text-center">Calculating...</td>
<td th:case="${entry != 0}" class="text-center table-danger">Missing</td>
<td th:case="true" class="text-center text-muted">N/A</td>
</div>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div th:if="${cache.indexes}" th:remove="tag">
<h2>Version list</h2>
<div class="table-responsive">
<table class="table table-striped table-bordered table-hover">
<thead class="table-dark">
<tr>
<th>Index</th>
<th>Files</th>
<th>Size<sup><a href="/caches#size">2</a></sup></th>
</tr>
</thead>
<tbody>
<tr th:each="index, it : ${cache.indexes}">
<td th:text="${it.index + 1}" class="text-end">0</td>
<td th:classappend="${index.allFilesValid}? 'table-success' : 'table-warning'" class="text-end">
<span th:text="${#numbers.formatInteger(index.validFiles, 1, 'COMMA')} + '&nbsp;/&nbsp;' + ${#numbers.formatInteger(index.files, 1, 'COMMA')}"></span>
<br />
<span th:text="'(' + ${#numbers.formatPercent(index.validFilesFraction, 1, 2)} + ')'"></span>
</td>
<!--/*@thymesVar id="#byteunits" type="org.openrs2.archive.web.ByteUnits"*/-->
<td th:text="${#byteunits.format(index.size)}" class="text-end">0 B</td>
</tr>
</tbody>
</table>
</div>
</div>
</div> </div>
</main> </main>
</body> </body>

@ -52,7 +52,7 @@
<ul> <ul>
<li><a href="https://displee.com/archive/">Displee's archive</a></li> <li><a href="https://displee.com/archive/">Displee's archive</a></li>
<li><a href="https://gregs.world/archive/">Greg's archive</a></li> <li><a href="https://gregs.world/archive/">Greg's archive</a></li>
<li><a href="https://openosrs.com/">OpenOSRS</a></li> <li><a href="https://www.hdos.dev/">HDOS</a></li>
<li><a href="https://archive.runestats.com/">Polar's archive</a></li> <li><a href="https://archive.runestats.com/">Polar's archive</a></li>
<!-- We don't use Moparisthebest's or RS-Hacking's --> <!-- We don't use Moparisthebest's or RS-Hacking's -->
<!-- data yet, but we will once we start archiving clients. --> <!-- data yet, but we will once we start archiving clients. -->
@ -60,6 +60,7 @@
<!-- <li><a href="https://rs-hacking.com/">RS-Hacking</a></li> --> <!-- <li><a href="https://rs-hacking.com/">RS-Hacking</a></li> -->
<li><a href="https://runearchive.org/">RuneArchive</a></li> <li><a href="https://runearchive.org/">RuneArchive</a></li>
<li><a href="https://runelite.net/">RuneLite</a></li> <li><a href="https://runelite.net/">RuneLite</a></li>
<li><a href="https://rs-archive.github.io/">The RuneScape Archive</a></li>
<li><a href="https://runescape.wiki/w/User:Manpaint55/RPU">The RuneScape Preservation Unit</a></li> <li><a href="https://runescape.wiki/w/User:Manpaint55/RPU">The RuneScape Preservation Unit</a></li>
<li><a href="http://runestar.org/">RuneStar</a></li> <li><a href="http://runestar.org/">RuneStar</a></li>
<li><a href="https://www.runewiki.org/">RuneWiki</a></li> <li><a href="https://www.runewiki.org/">RuneWiki</a></li>

@ -13,6 +13,7 @@
<script src="/webjars/bootstrap-table/dist/bootstrap-table.min.js" defer></script> <script src="/webjars/bootstrap-table/dist/bootstrap-table.min.js" defer></script>
<script src="/webjars/bootstrap-table/dist/extensions/filter-control/bootstrap-table-filter-control.min.js" defer></script> <script src="/webjars/bootstrap-table/dist/extensions/filter-control/bootstrap-table-filter-control.min.js" defer></script>
<script src="/webjars/bootstrap-table/dist/extensions/sticky-header/bootstrap-table-sticky-header.min.js" defer></script> <script src="/webjars/bootstrap-table/dist/extensions/sticky-header/bootstrap-table-sticky-header.min.js" defer></script>
<script src="/static/js/openrs2.js" defer></script>
</head> </head>
<body> <body>
<nav class="navbar navbar-dark navbar-expand bg-dark mb-4" th:fragment="nav"> <nav class="navbar navbar-dark navbar-expand bg-dark mb-4" th:fragment="nav">
@ -26,6 +27,9 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" th:classappend="${active == 'keys'}? 'active'" href="/keys">Keys</a> <a class="nav-link" th:classappend="${active == 'keys'}? 'active'" href="/keys">Keys</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" th:classappend="${active == 'api'}? 'active'" href="/api">API</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="/pub">Other</a> <a class="nav-link" href="/pub">Other</a>
</li> </li>

@ -6,7 +6,7 @@ plugins {
dependencies { dependencies {
api(projects.util) api(projects.util)
api(libs.bundles.asm) api(libs.bundles.asm)
api(libs.guice) api(libs.bundles.guice)
api(libs.jackson.databind) api(libs.jackson.databind)
api(libs.netty.buffer) api(libs.netty.buffer)

@ -109,12 +109,14 @@ public fun AbstractInsnNode.remap(remapper: ExtendedRemapper) {
name = remapper.mapFieldName(originalOwner, name, desc) name = remapper.mapFieldName(originalOwner, name, desc)
desc = remapper.mapDesc(desc) desc = remapper.mapDesc(desc)
} }
is MethodInsnNode -> { is MethodInsnNode -> {
val originalOwner = owner val originalOwner = owner
owner = remapper.mapMethodOwner(originalOwner, name, desc) owner = remapper.mapMethodOwner(originalOwner, name, desc)
name = remapper.mapMethodName(originalOwner, name, desc) name = remapper.mapMethodName(originalOwner, name, desc)
desc = remapper.mapDesc(desc) desc = remapper.mapDesc(desc)
} }
is InvokeDynamicInsnNode -> throw UnsupportedOperationException() is InvokeDynamicInsnNode -> throw UnsupportedOperationException()
is TypeInsnNode -> desc = remapper.mapType(desc) is TypeInsnNode -> desc = remapper.mapType(desc)
is LdcInsnNode -> cst = remapper.mapValue(cst) is LdcInsnNode -> cst = remapper.mapValue(cst)

@ -245,6 +245,7 @@ public val AbstractInsnNode.intConstant: Int?
null null
} }
} }
is LdcInsnNode -> { is LdcInsnNode -> {
val cst = cst val cst = cst
if (cst is Int) { if (cst is Int) {
@ -253,6 +254,7 @@ public val AbstractInsnNode.intConstant: Int?
null null
} }
} }
else -> when (opcode) { else -> when (opcode) {
Opcodes.ICONST_M1 -> -1 Opcodes.ICONST_M1 -> -1
Opcodes.ICONST_0 -> 0 Opcodes.ICONST_0 -> 0

@ -99,6 +99,7 @@ public fun MethodNode.removeArgument(argIndex: Int) {
newLocalIndexUsed = true newLocalIndexUsed = true
} }
} }
is IincInsnNode -> { is IincInsnNode -> {
insn.`var` = remap(insn.`var`, argType, localIndex, newLocalIndex) insn.`var` = remap(insn.`var`, argType, localIndex, newLocalIndex)
@ -106,6 +107,7 @@ public fun MethodNode.removeArgument(argIndex: Int) {
newLocalIndexUsed = true newLocalIndexUsed = true
} }
} }
is FrameNode -> throw UnsupportedOperationException("SKIP_FRAMES and COMPUTE_FRAMES must be used") is FrameNode -> throw UnsupportedOperationException("SKIP_FRAMES and COMPUTE_FRAMES must be used")
} }
} }

@ -188,6 +188,7 @@ public val AbstractInsnNode.stackMetadata: StackMetadata
} else { } else {
PUSH1 PUSH1
} }
is FieldInsnNode -> { is FieldInsnNode -> {
val fieldSize = Type.getType(desc).size val fieldSize = Type.getType(desc).size
var pushes = 0 var pushes = 0
@ -202,6 +203,7 @@ public val AbstractInsnNode.stackMetadata: StackMetadata
} }
StackMetadata(pops, pushes) StackMetadata(pops, pushes)
} }
is MethodInsnNode -> { is MethodInsnNode -> {
val argumentsAndReturnSizes = Type.getArgumentsAndReturnSizes(desc) val argumentsAndReturnSizes = Type.getArgumentsAndReturnSizes(desc)
val pushes = argumentsAndReturnSizes and 0x3 val pushes = argumentsAndReturnSizes and 0x3
@ -211,6 +213,7 @@ public val AbstractInsnNode.stackMetadata: StackMetadata
} }
StackMetadata(pops, pushes) StackMetadata(pops, pushes)
} }
is InvokeDynamicInsnNode -> throw UnsupportedOperationException() is InvokeDynamicInsnNode -> throw UnsupportedOperationException()
is MultiANewArrayInsnNode -> StackMetadata(dims, 1) is MultiANewArrayInsnNode -> StackMetadata(dims, 1)
else -> SIMPLE_OPCODES[opcode] ?: throw IllegalArgumentException() else -> SIMPLE_OPCODES[opcode] ?: throw IllegalArgumentException()

@ -35,6 +35,7 @@ public object Glob {
} else { } else {
regex.append(".*") regex.append(".*")
} }
else -> regex.append(Regex.escape(ch.toString())) else -> regex.append(Regex.escape(ch.toString()))
} }
} }

@ -94,6 +94,7 @@ public class ConstantPool private constructor(
addMethodRef(methodRef) addMethodRef(methodRef)
} }
} }
is FieldInsnNode -> addFieldRef(MemberRef(insn.owner, insn.name, insn.desc)) is FieldInsnNode -> addFieldRef(MemberRef(insn.owner, insn.name, insn.desc))
is TypeInsnNode -> strings += insn.desc is TypeInsnNode -> strings += insn.desc
} }
@ -155,6 +156,7 @@ public class ConstantPool private constructor(
throw IllegalArgumentException("Unsupported constant type: ${value.sort}") throw IllegalArgumentException("Unsupported constant type: ${value.sort}")
} }
} }
else -> throw IllegalArgumentException("Unsupported constant type: ${value.javaClass.name}") else -> throw IllegalArgumentException("Unsupported constant type: ${value.javaClass.name}")
} }
} }

@ -42,7 +42,7 @@ public object PackClass {
Opcodes.V1_3, Opcodes.V1_3,
Opcodes.V1_4, Opcodes.V1_4,
Opcodes.V1_5, Opcodes.V1_5,
Opcodes.V1_6, Opcodes.V1_6
) )
private const val INT_DESCRIPTOR = "I" private const val INT_DESCRIPTOR = "I"
@ -503,11 +503,13 @@ public object PackClass {
branchLen += (cases + 2) * 4 branchLen += (cases + 2) * 4
sipushAndSwitchLen += 4 sipushAndSwitchLen += 4
} }
Opcodes.LOOKUPSWITCH -> { Opcodes.LOOKUPSWITCH -> {
val cases = buf.readVarInt() val cases = buf.readVarInt()
branchLen += (cases + 1) * 4 branchLen += (cases + 1) * 4
sipushAndSwitchLen += cases * 4 sipushAndSwitchLen += cases * 4
} }
Opcodes.INVOKEINTERFACE -> interfaceMethodRefLen += 2 Opcodes.INVOKEINTERFACE -> interfaceMethodRefLen += 2
Opcodes.NEWARRAY -> newArrayLen++ Opcodes.NEWARRAY -> newArrayLen++
Opcodes.MULTIANEWARRAY -> multiNewArrayLen++ Opcodes.MULTIANEWARRAY -> multiNewArrayLen++
@ -1155,10 +1157,12 @@ public object PackClass {
) )
} }
} }
insn.`var` < 256 -> { insn.`var` < 256 -> {
buf.writeByte(insn.opcode) buf.writeByte(insn.opcode)
localVarBuf.writeByte(insn.`var`) localVarBuf.writeByte(insn.`var`)
} }
else -> { else -> {
buf.writeByte(WIDE) buf.writeByte(WIDE)
buf.writeByte(insn.opcode) buf.writeByte(insn.opcode)
@ -1166,28 +1170,34 @@ public object PackClass {
} }
} }
} }
is LdcInsnNode -> { is LdcInsnNode -> {
when (val value = insn.cst) { when (val value = insn.cst) {
is Int -> { is Int -> {
buf.writeByte(LDC_INT) buf.writeByte(LDC_INT)
constantPool.writeInt(constantBuf, value) constantPool.writeInt(constantBuf, value)
} }
is Long -> { is Long -> {
buf.writeByte(LDC_LONG) buf.writeByte(LDC_LONG)
constantPool.writeLong(wideConstantBuf, value) constantPool.writeLong(wideConstantBuf, value)
} }
is Float -> { is Float -> {
buf.writeByte(LDC_FLOAT) buf.writeByte(LDC_FLOAT)
constantPool.writeFloat(constantBuf, value) constantPool.writeFloat(constantBuf, value)
} }
is Double -> { is Double -> {
buf.writeByte(LDC_DOUBLE) buf.writeByte(LDC_DOUBLE)
constantPool.writeDouble(wideConstantBuf, value) constantPool.writeDouble(wideConstantBuf, value)
} }
is String -> { is String -> {
buf.writeByte(LDC_STRING) buf.writeByte(LDC_STRING)
constantPool.writeString(constantBuf, value) constantPool.writeString(constantBuf, value)
} }
is Type -> { is Type -> {
if (value.sort == Type.OBJECT) { if (value.sort == Type.OBJECT) {
buf.writeByte(LDC_CLASS) buf.writeByte(LDC_CLASS)
@ -1198,19 +1208,23 @@ public object PackClass {
) )
} }
} }
else -> throw IllegalArgumentException( else -> throw IllegalArgumentException(
"Unsupported constant type: ${value.javaClass.name}" "Unsupported constant type: ${value.javaClass.name}"
) )
} }
} }
is TypeInsnNode -> { is TypeInsnNode -> {
buf.writeByte(insn.opcode) buf.writeByte(insn.opcode)
constantPool.writeString(classBuf, insn.desc) constantPool.writeString(classBuf, insn.desc)
} }
is FieldInsnNode -> { is FieldInsnNode -> {
buf.writeByte(insn.opcode) buf.writeByte(insn.opcode)
constantPool.writeFieldRef(fieldRefBuf, MemberRef(insn.owner, insn.name, insn.desc)) constantPool.writeFieldRef(fieldRefBuf, MemberRef(insn.owner, insn.name, insn.desc))
} }
is MethodInsnNode -> { is MethodInsnNode -> {
buf.writeByte(insn.opcode) buf.writeByte(insn.opcode)
@ -1221,6 +1235,7 @@ public object PackClass {
constantPool.writeMethodRef(methodRefBuf, methodRef) constantPool.writeMethodRef(methodRefBuf, methodRef)
} }
} }
is JumpInsnNode -> { is JumpInsnNode -> {
val targetPc = insns[i].indexOf(insn.label.nextReal) val targetPc = insns[i].indexOf(insn.label.nextReal)
val delta = targetPc - pc val delta = targetPc - pc
@ -1239,6 +1254,7 @@ public object PackClass {
branchBuf.writeInt(delta) branchBuf.writeInt(delta)
} }
} }
is IntInsnNode -> { is IntInsnNode -> {
buf.writeByte(insn.opcode) buf.writeByte(insn.opcode)
@ -1249,6 +1265,7 @@ public object PackClass {
else -> throw IllegalArgumentException("Unsupported IntInsnNode opcode: ${insn.opcode}") else -> throw IllegalArgumentException("Unsupported IntInsnNode opcode: ${insn.opcode}")
} }
} }
is IincInsnNode -> { is IincInsnNode -> {
if (insn.`var` < 256 && (insn.incr >= -128 && insn.incr <= 127)) { if (insn.`var` < 256 && (insn.incr >= -128 && insn.incr <= 127)) {
buf.writeByte(insn.opcode) buf.writeByte(insn.opcode)
@ -1261,6 +1278,7 @@ public object PackClass {
wideIincBuf.writeShort(insn.incr) wideIincBuf.writeShort(insn.incr)
} }
} }
is TableSwitchInsnNode -> { is TableSwitchInsnNode -> {
buf.writeByte(insn.opcode) buf.writeByte(insn.opcode)
@ -1275,6 +1293,7 @@ public object PackClass {
branchBuf.writeInt(targetPc - pc) branchBuf.writeInt(targetPc - pc)
} }
} }
is LookupSwitchInsnNode -> { is LookupSwitchInsnNode -> {
buf.writeByte(insn.opcode) buf.writeByte(insn.opcode)
@ -1295,11 +1314,13 @@ public object PackClass {
branchBuf.writeInt(targetPc - pc) branchBuf.writeInt(targetPc - pc)
} }
} }
is MultiANewArrayInsnNode -> { is MultiANewArrayInsnNode -> {
buf.writeByte(insn.opcode) buf.writeByte(insn.opcode)
constantPool.writeString(classBuf, insn.desc) constantPool.writeString(classBuf, insn.desc)
multiNewArrayBuf.writeByte(insn.dims) multiNewArrayBuf.writeByte(insn.dims)
} }
is InsnNode -> buf.writeByte(insn.opcode) is InsnNode -> buf.writeByte(insn.opcode)
else -> throw IllegalArgumentException("Unsupported instruction type: ${insn.javaClass.name}") else -> throw IllegalArgumentException("Unsupported instruction type: ${insn.javaClass.name}")
} }
@ -1552,6 +1573,7 @@ public object PackClass {
throw IllegalArgumentException("Unsupported constant type: ${value.sort}") throw IllegalArgumentException("Unsupported constant type: ${value.sort}")
} }
} }
else -> throw IllegalArgumentException("Unsupported constant type: ${value.javaClass.name}") else -> throw IllegalArgumentException("Unsupported constant type: ${value.javaClass.name}")
} }
} }

@ -5,7 +5,7 @@ plugins {
} }
application { application {
mainClass.set("org.openrs2.buffer.generator.GenerateBufferCommand") mainClass.set("org.openrs2.buffer.generator.GenerateBufferCommandKt")
} }
dependencies { dependencies {

@ -9,7 +9,7 @@ public class ByteBufExtensionGenerator {
public fun generate(): String { public fun generate(): String {
val builder = FileSpec.builder("org.openrs2.buffer", "GeneratedByteBufExtensions") val builder = FileSpec.builder("org.openrs2.buffer", "GeneratedByteBufExtensions")
builder.indent(" ") builder.indent(" ")
builder.addComment("This file is generated automatically. DO NOT EDIT.") builder.addFileComment("This file is generated automatically. DO NOT EDIT.")
for (type in IntType.values()) { for (type in IntType.values()) {
for (order in ByteOrder.values()) { for (order in ByteOrder.values()) {

@ -19,6 +19,7 @@ public enum class ByteOrder(public val suffix: String) {
else -> 8 else -> 8
} }
} }
ALT3_REVERSE -> { ALT3_REVERSE -> {
require(width == 4) require(width == 4)
when (i) { when (i) {

@ -4,7 +4,7 @@ plugins {
} }
dependencies { dependencies {
api(libs.guice) api(libs.bundles.guice)
api(libs.netty.buffer) api(libs.netty.buffer)
implementation(projects.util) implementation(projects.util)

@ -1,6 +1,5 @@
import org.jetbrains.dokka.gradle.DokkaTask import org.jetbrains.dokka.gradle.DokkaTask
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginWrapper import org.jetbrains.kotlin.gradle.plugin.KotlinPluginWrapper
import org.jetbrains.kotlin.gradle.plugin.getKotlinPluginVersion
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jmailen.gradle.kotlinter.KotlinterExtension import org.jmailen.gradle.kotlinter.KotlinterExtension
import org.jmailen.gradle.kotlinter.KotlinterPlugin import org.jmailen.gradle.kotlinter.KotlinterPlugin
@ -53,7 +52,7 @@ allprojects {
plugins.withType<KotlinterPlugin> { plugins.withType<KotlinterPlugin> {
configure<KotlinterExtension> { configure<KotlinterExtension> {
// see https://github.com/pinterest/ktlint/issues/764 // see https://github.com/pinterest/ktlint/issues/764
disabledRules = arrayOf("indent", "parameter-list-wrapping") disabledRules = arrayOf("argument-list-wrapping", "parameter-list-wrapping", "wrapping")
} }
} }
@ -126,15 +125,6 @@ configure(subprojects.filter { it.isFree }) {
apply(plugin = "org.jmailen.kotlinter") apply(plugin = "org.jmailen.kotlinter")
dependencies { dependencies {
val api by configurations
for (module in listOf("stdlib", "stdlib-common", "stdlib-jdk7", "stdlib-jdk8")) {
api("org.jetbrains.kotlin:kotlin-$module") {
version {
strictly(project.getKotlinPluginVersion())
}
}
}
val implementation by configurations val implementation by configurations
implementation(kotlin("reflect")) implementation(kotlin("reflect"))
implementation(libs.inlineLogger) implementation(libs.inlineLogger)

@ -5,7 +5,7 @@ plugins {
dependencies { dependencies {
api(projects.cache) api(projects.cache)
api(libs.guice) api(libs.bundles.guice)
implementation(projects.buffer) implementation(projects.buffer)
implementation(projects.util) implementation(projects.util)

@ -37,6 +37,7 @@ public class EnumType(id: Int) : ConfigType(id) {
this.strings = strings this.strings = strings
} }
6 -> { 6 -> {
val size = buf.readUnsignedShort() val size = buf.readUnsignedShort()
val ints = Int2IntOpenHashMap() val ints = Int2IntOpenHashMap()
@ -48,6 +49,7 @@ public class EnumType(id: Int) : ConfigType(id) {
this.ints = ints this.ints = ints
} }
else -> throw IllegalArgumentException("Unsupported config code: $code") else -> throw IllegalArgumentException("Unsupported config code: $code")
} }
} }

@ -26,6 +26,7 @@ public class StructType(id: Int) : ConfigType(id) {
} }
} }
} }
else -> throw IllegalArgumentException("Unsupported config code: $code") else -> throw IllegalArgumentException("Unsupported config code: $code")
} }
} }
@ -42,11 +43,13 @@ public class StructType(id: Int) : ConfigType(id) {
buf.writeMedium(id) buf.writeMedium(id)
buf.writeString(value) buf.writeString(value)
} }
is Int -> { is Int -> {
buf.writeBoolean(false) buf.writeBoolean(false)
buf.writeMedium(id) buf.writeMedium(id)
buf.writeInt(value) buf.writeInt(value)
} }
else -> throw IllegalStateException() else -> throw IllegalStateException()
} }
} }

@ -15,6 +15,7 @@ public class VarbitType(id: Int) : ConfigType(id) {
startBit = buf.readUnsignedByte().toInt() startBit = buf.readUnsignedByte().toInt()
endBit = buf.readUnsignedByte().toInt() endBit = buf.readUnsignedByte().toInt()
} }
else -> throw IllegalArgumentException("Unsupported config code: $code") else -> throw IllegalArgumentException("Unsupported config code: $code")
} }
} }

@ -196,11 +196,13 @@ public object Song {
onVelocity = (onVelocity + onVelocityBuf.readUnsignedByte().toInt()) and 0x7F onVelocity = (onVelocity + onVelocityBuf.readUnsignedByte().toInt()) and 0x7F
ShortMessage(ShortMessage.NOTE_ON, channel, key, onVelocity) ShortMessage(ShortMessage.NOTE_ON, channel, key, onVelocity)
} }
NOTE_OFF -> { NOTE_OFF -> {
key = (key + keyBuf.readUnsignedByte().toInt()) and 0x7F key = (key + keyBuf.readUnsignedByte().toInt()) and 0x7F
offVelocity = (offVelocity + offVelocityBuf.readUnsignedByte().toInt()) and 0x7F offVelocity = (offVelocity + offVelocityBuf.readUnsignedByte().toInt()) and 0x7F
ShortMessage(ShortMessage.NOTE_OFF, channel, key, offVelocity) ShortMessage(ShortMessage.NOTE_OFF, channel, key, offVelocity)
} }
CONTROL_CHANGE -> { CONTROL_CHANGE -> {
controller = (controller + controllerBuf.readUnsignedByte()) and 0x7F controller = (controller + controllerBuf.readUnsignedByte()) and 0x7F
@ -218,6 +220,7 @@ public object Song {
REGISTERED_LSB -> registeredLsbBuf.readUnsignedByte().toInt() REGISTERED_LSB -> registeredLsbBuf.readUnsignedByte().toInt()
DAMPER, PORTAMENTO, ALL_SOUND_OFF, RESET_CONTROLLERS, ALL_NOTES_OFF -> DAMPER, PORTAMENTO, ALL_SOUND_OFF, RESET_CONTROLLERS, ALL_NOTES_OFF ->
otherKnownControllerBuf.readUnsignedByte().toInt() otherKnownControllerBuf.readUnsignedByte().toInt()
else -> unknownControllerBuf.readUnsignedByte().toInt() else -> unknownControllerBuf.readUnsignedByte().toInt()
} }
@ -225,25 +228,30 @@ public object Song {
values[controller] = value values[controller] = value
ShortMessage(ShortMessage.CONTROL_CHANGE, channel, controller, value and 0x7F) ShortMessage(ShortMessage.CONTROL_CHANGE, channel, controller, value and 0x7F)
} }
PITCH_WHEEL_CHANGE -> { PITCH_WHEEL_CHANGE -> {
pitchWheel += pitchWheelLsbBuf.readUnsignedByte().toInt() pitchWheel += pitchWheelLsbBuf.readUnsignedByte().toInt()
pitchWheel += (pitchWheelMsbBuf.readUnsignedByte().toInt() shl 7) pitchWheel += (pitchWheelMsbBuf.readUnsignedByte().toInt() shl 7)
pitchWheel = pitchWheel and 0x3FFF pitchWheel = pitchWheel and 0x3FFF
ShortMessage(ShortMessage.PITCH_BEND, channel, pitchWheel and 0x7F, pitchWheel shr 7) ShortMessage(ShortMessage.PITCH_BEND, channel, pitchWheel and 0x7F, pitchWheel shr 7)
} }
CHANNEL_PRESSURE_CHANGE -> { CHANNEL_PRESSURE_CHANGE -> {
channelPressure = (channelPressure + channelPressureBuf.readUnsignedByte().toInt()) and 0x7F channelPressure = (channelPressure + channelPressureBuf.readUnsignedByte().toInt()) and 0x7F
ShortMessage(ShortMessage.CHANNEL_PRESSURE, channel, channelPressure, 0) ShortMessage(ShortMessage.CHANNEL_PRESSURE, channel, channelPressure, 0)
} }
KEY_PRESSURE_CHANGE -> { KEY_PRESSURE_CHANGE -> {
key = (key + keyBuf.readUnsignedByte().toInt()) and 0x7F key = (key + keyBuf.readUnsignedByte().toInt()) and 0x7F
keyPressure = (keyPressure + keyPressureBuf.readUnsignedByte().toInt()) and 0x7F keyPressure = (keyPressure + keyPressureBuf.readUnsignedByte().toInt()) and 0x7F
ShortMessage(ShortMessage.POLY_PRESSURE, channel, key, keyPressure) ShortMessage(ShortMessage.POLY_PRESSURE, channel, key, keyPressure)
} }
PROGRAM_CHANGE -> { PROGRAM_CHANGE -> {
val bankSelect = bankSelectBuf.readUnsignedByte().toInt() val bankSelect = bankSelectBuf.readUnsignedByte().toInt()
ShortMessage(ShortMessage.PROGRAM_CHANGE, channel, bankSelect, 0) ShortMessage(ShortMessage.PROGRAM_CHANGE, channel, bankSelect, 0)
} }
else -> throw IllegalStateException() else -> throw IllegalStateException()
} }
@ -317,9 +325,11 @@ public object Song {
require(message.data.size == 3) require(message.data.size == 3)
tempoBuf.writeBytes(message.data) tempoBuf.writeBytes(message.data)
} }
else -> throw IllegalArgumentException("Unsupported meta type: $type") else -> throw IllegalArgumentException("Unsupported meta type: $type")
} }
} }
is ShortMessage -> { is ShortMessage -> {
val command = message.status and 0xF0 val command = message.status and 0xF0
val channel = message.status and 0xF val channel = message.status and 0xF
@ -354,11 +364,13 @@ public object Song {
onVelocityBuf.writeByte((onVelocity - prevOnVelocity) and 0x7F) onVelocityBuf.writeByte((onVelocity - prevOnVelocity) and 0x7F)
prevOnVelocity = onVelocity prevOnVelocity = onVelocity
} }
ShortMessage.NOTE_OFF -> { ShortMessage.NOTE_OFF -> {
val offVelocity = message.data2 val offVelocity = message.data2
offVelocityBuf.writeByte((offVelocity - prevOffVelocity) and 0x7F) offVelocityBuf.writeByte((offVelocity - prevOffVelocity) and 0x7F)
prevOffVelocity = offVelocity prevOffVelocity = offVelocity
} }
ShortMessage.CONTROL_CHANGE -> { ShortMessage.CONTROL_CHANGE -> {
val controller = message.data1 val controller = message.data1
controllerBuf.writeByte((controller - prevController) and 0x7F) controllerBuf.writeByte((controller - prevController) and 0x7F)
@ -381,11 +393,13 @@ public object Song {
REGISTERED_LSB -> registeredLsbBuf.writeByte(valueDelta) REGISTERED_LSB -> registeredLsbBuf.writeByte(valueDelta)
DAMPER, PORTAMENTO, ALL_SOUND_OFF, RESET_CONTROLLERS, ALL_NOTES_OFF -> DAMPER, PORTAMENTO, ALL_SOUND_OFF, RESET_CONTROLLERS, ALL_NOTES_OFF ->
otherKnownControllerBuf.writeByte(valueDelta) otherKnownControllerBuf.writeByte(valueDelta)
else -> unknownControllerBuf.writeByte(valueDelta) else -> unknownControllerBuf.writeByte(valueDelta)
} }
prevValues[controller] = value prevValues[controller] = value
} }
ShortMessage.PITCH_BEND -> { ShortMessage.PITCH_BEND -> {
val pitchWheel = message.data1 or (message.data2 shl 7) val pitchWheel = message.data1 or (message.data2 shl 7)
val pitchWheelDelta = (pitchWheel - prevPitchWheel) and 0x3FFF val pitchWheelDelta = (pitchWheel - prevPitchWheel) and 0x3FFF
@ -393,22 +407,27 @@ public object Song {
pitchWheelMsbBuf.writeByte(pitchWheelDelta shr 7) pitchWheelMsbBuf.writeByte(pitchWheelDelta shr 7)
prevPitchWheel = pitchWheel prevPitchWheel = pitchWheel
} }
ShortMessage.CHANNEL_PRESSURE -> { ShortMessage.CHANNEL_PRESSURE -> {
val channelPressure = message.data1 val channelPressure = message.data1
channelPressureBuf.writeByte((channelPressure - prevChannelPressure) and 0x7F) channelPressureBuf.writeByte((channelPressure - prevChannelPressure) and 0x7F)
prevChannelPressure = channelPressure prevChannelPressure = channelPressure
} }
ShortMessage.POLY_PRESSURE -> { ShortMessage.POLY_PRESSURE -> {
val keyPressure = message.data2 val keyPressure = message.data2
keyPressureBuf.writeByte((keyPressure - prevKeyPressure) and 0x7F) keyPressureBuf.writeByte((keyPressure - prevKeyPressure) and 0x7F)
prevKeyPressure = keyPressure prevKeyPressure = keyPressure
} }
ShortMessage.PROGRAM_CHANGE -> { ShortMessage.PROGRAM_CHANGE -> {
bankSelectBuf.writeByte(message.data1) bankSelectBuf.writeByte(message.data1)
} }
else -> throw IllegalStateException() else -> throw IllegalStateException()
} }
} }
else -> throw IllegalArgumentException("Unsupported message type: ${message.javaClass.name}") else -> throw IllegalArgumentException("Unsupported message type: ${message.javaClass.name}")
} }
} }

@ -14,7 +14,7 @@ public class Sprite private constructor(
val xOffset: Int, val xOffset: Int,
val yOffset: Int, val yOffset: Int,
val innerWidth: Int, val innerWidth: Int,
val innerHeight: Int, val innerHeight: Int
) { ) {
val pixels = ByteArray(innerWidth * innerHeight) val pixels = ByteArray(innerWidth * innerHeight)
var alpha: ByteArray? = null var alpha: ByteArray? = null

@ -0,0 +1,32 @@
plugins {
`maven-publish`
application
kotlin("jvm")
}
application {
mainClass.set("org.openrs2.cache.cli.CacheCommandKt")
}
dependencies {
api(libs.clikt)
implementation(projects.cache)
implementation(projects.inject)
}
publishing {
publications.create<MavenPublication>("maven") {
from(components["java"])
pom {
packaging = "jar"
name.set("OpenRS2 Cache CLI")
description.set(
"""
Tools for working with RuneScape caches.
""".trimIndent()
)
}
}
}

@ -0,0 +1,15 @@
package org.openrs2.cache.cli
import com.github.ajalt.clikt.core.NoOpCliktCommand
import com.github.ajalt.clikt.core.subcommands
public fun main(args: Array<String>): Unit = CacheCommand().main(args)
public class CacheCommand : NoOpCliktCommand(name = "cache") {
init {
subcommands(
OpenNxtUnpackCommand(),
RuneLiteUnpackCommand()
)
}
}

@ -0,0 +1,26 @@
package org.openrs2.cache.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.types.path
import com.google.inject.Guice
import io.netty.buffer.ByteBufAllocator
import org.openrs2.cache.CacheModule
import org.openrs2.cache.OpenNxtStore
import org.openrs2.cache.Store
import org.openrs2.inject.CloseableInjector
public class OpenNxtUnpackCommand : CliktCommand(name = "unpack-opennxt") {
private val input by argument().path(mustExist = true, canBeFile = false, mustBeReadable = true)
private val output by argument().path(canBeFile = false, mustBeReadable = true, mustBeWritable = true)
override fun run() {
CloseableInjector(Guice.createInjector(CacheModule)).use { injector ->
val alloc = injector.getInstance(ByteBufAllocator::class.java)
Store.open(output, alloc).use { store ->
OpenNxtStore.unpack(input, store)
}
}
}
}

@ -0,0 +1,27 @@
package org.openrs2.cache.cli
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.parameters.arguments.argument
import com.github.ajalt.clikt.parameters.types.path
import com.google.inject.Guice
import io.netty.buffer.ByteBufAllocator
import org.openrs2.cache.CacheModule
import org.openrs2.cache.RuneLiteStore
import org.openrs2.cache.Store
import org.openrs2.inject.CloseableInjector
public class RuneLiteUnpackCommand : CliktCommand(name = "unpack-runelite") {
private val input by argument().path(mustExist = true, canBeFile = false, mustBeReadable = true)
private val output by argument().path(canBeFile = false, mustBeReadable = true, mustBeWritable = true)
override fun run() {
CloseableInjector(Guice.createInjector(CacheModule)).use { injector ->
val alloc = injector.getInstance(ByteBufAllocator::class.java)
val runeLiteStore = injector.getInstance(RuneLiteStore::class.java)
Store.open(output, alloc).use { store ->
runeLiteStore.unpack(input, store)
}
}
}
}

@ -5,14 +5,15 @@ plugins {
dependencies { dependencies {
api(projects.crypto) api(projects.crypto)
api(libs.bundles.guice)
api(libs.commons.compress) api(libs.commons.compress)
api(libs.fastutil) api(libs.fastutil)
api(libs.guice)
api(libs.netty.buffer) api(libs.netty.buffer)
implementation(projects.buffer) implementation(projects.buffer)
implementation(projects.compress) implementation(projects.compress)
implementation(projects.util) implementation(projects.util)
implementation(libs.sqlite)
testImplementation(libs.jimfs) testImplementation(libs.jimfs)
} }

@ -161,6 +161,17 @@ public abstract class Archive internal constructor(
return existsNamed(group.krHashCode(), file.krHashCode()) return existsNamed(group.krHashCode(), file.krHashCode())
} }
public fun existsNamedGroup(groupNameHash: Int, file: Int): Boolean {
require(file >= 0)
val entry = index.getNamed(groupNameHash) ?: return false
return entry.contains(file)
}
public fun exists(group: String, file: Int): Boolean {
return existsNamedGroup(group.krHashCode(), file)
}
public fun list(): Iterator<Js5Index.Group<*>> { public fun list(): Iterator<Js5Index.Group<*>> {
return index.iterator() return index.iterator()
} }
@ -181,6 +192,7 @@ public abstract class Archive internal constructor(
return listNamed(group.krHashCode()) return listNamed(group.krHashCode())
} }
@JvmOverloads
public fun read(group: Int, file: Int, key: XteaKey = XteaKey.ZERO): ByteBuf { public fun read(group: Int, file: Int, key: XteaKey = XteaKey.ZERO): ByteBuf {
require(group >= 0 && file >= 0) require(group >= 0 && file >= 0)
@ -189,16 +201,33 @@ public abstract class Archive internal constructor(
return unpacked.read(file) return unpacked.read(file)
} }
@JvmOverloads
public fun readNamed(groupNameHash: Int, fileNameHash: Int, key: XteaKey = XteaKey.ZERO): ByteBuf { public fun readNamed(groupNameHash: Int, fileNameHash: Int, key: XteaKey = XteaKey.ZERO): ByteBuf {
val entry = index.getNamed(groupNameHash) ?: throw FileNotFoundException() val entry = index.getNamed(groupNameHash) ?: throw FileNotFoundException()
val unpacked = getUnpacked(entry, key) val unpacked = getUnpacked(entry, key)
return unpacked.readNamed(fileNameHash) return unpacked.readNamed(fileNameHash)
} }
@JvmOverloads
public fun read(group: String, file: String, key: XteaKey = XteaKey.ZERO): ByteBuf { public fun read(group: String, file: String, key: XteaKey = XteaKey.ZERO): ByteBuf {
return readNamed(group.krHashCode(), file.krHashCode(), key) return readNamed(group.krHashCode(), file.krHashCode(), key)
} }
@JvmOverloads
public fun readNamedGroup(groupNameHash: Int, file: Int, key: XteaKey = XteaKey.ZERO): ByteBuf {
require(file >= 0)
val entry = index.getNamed(groupNameHash) ?: throw FileNotFoundException()
val unpacked = getUnpacked(entry, key)
return unpacked.read(file)
}
@JvmOverloads
public fun read(group: String, file: Int, key: XteaKey = XteaKey.ZERO): ByteBuf {
return readNamedGroup(group.krHashCode(), file, key)
}
@JvmOverloads
public fun write(group: Int, file: Int, buf: ByteBuf, key: XteaKey = XteaKey.ZERO) { public fun write(group: Int, file: Int, buf: ByteBuf, key: XteaKey = XteaKey.ZERO) {
require(group >= 0 && file >= 0) require(group >= 0 && file >= 0)
@ -209,6 +238,7 @@ public abstract class Archive internal constructor(
dirty = true dirty = true
} }
@JvmOverloads
public fun writeNamed(groupNameHash: Int, fileNameHash: Int, buf: ByteBuf, key: XteaKey = XteaKey.ZERO) { public fun writeNamed(groupNameHash: Int, fileNameHash: Int, buf: ByteBuf, key: XteaKey = XteaKey.ZERO) {
val entry = index.createOrGetNamed(groupNameHash) val entry = index.createOrGetNamed(groupNameHash)
val unpacked = createOrGetUnpacked(entry, key, isOverwritingNamed(entry, fileNameHash)) val unpacked = createOrGetUnpacked(entry, key, isOverwritingNamed(entry, fileNameHash))
@ -218,10 +248,28 @@ public abstract class Archive internal constructor(
index.hasNames = true index.hasNames = true
} }
@JvmOverloads
public fun write(group: String, file: String, buf: ByteBuf, key: XteaKey = XteaKey.ZERO) { public fun write(group: String, file: String, buf: ByteBuf, key: XteaKey = XteaKey.ZERO) {
return writeNamed(group.krHashCode(), file.krHashCode(), buf, key) return writeNamed(group.krHashCode(), file.krHashCode(), buf, key)
} }
@JvmOverloads
public fun writeNamedGroup(groupNameHash: Int, file: Int, buf: ByteBuf, key: XteaKey = XteaKey.ZERO) {
require(file >= 0)
val entry = index.createOrGetNamed(groupNameHash)
val unpacked = createOrGetUnpacked(entry, key, isOverwriting(entry, file))
unpacked.write(file, buf)
dirty = true
index.hasNames = true
}
@JvmOverloads
public fun write(group: String, file: Int, buf: ByteBuf, key: XteaKey = XteaKey.ZERO) {
return writeNamedGroup(group.krHashCode(), file, buf, key)
}
public fun remove(group: Int) { public fun remove(group: Int) {
require(group >= 0) require(group >= 0)
@ -244,6 +292,7 @@ public abstract class Archive internal constructor(
return removeNamed(group.krHashCode()) return removeNamed(group.krHashCode())
} }
@JvmOverloads
public fun remove(group: Int, file: Int, key: XteaKey = XteaKey.ZERO) { public fun remove(group: Int, file: Int, key: XteaKey = XteaKey.ZERO) {
require(group >= 0 && file >= 0) require(group >= 0 && file >= 0)
@ -260,6 +309,7 @@ public abstract class Archive internal constructor(
dirty = true dirty = true
} }
@JvmOverloads
public fun removeNamed(groupNameHash: Int, fileNameHash: Int, key: XteaKey = XteaKey.ZERO) { public fun removeNamed(groupNameHash: Int, fileNameHash: Int, key: XteaKey = XteaKey.ZERO) {
val entry = index.getNamed(groupNameHash) ?: return val entry = index.getNamed(groupNameHash) ?: return
@ -274,10 +324,33 @@ public abstract class Archive internal constructor(
dirty = true dirty = true
} }
@JvmOverloads
public fun remove(group: String, file: String, key: XteaKey = XteaKey.ZERO) { public fun remove(group: String, file: String, key: XteaKey = XteaKey.ZERO) {
return removeNamed(group.krHashCode(), file.krHashCode(), key) return removeNamed(group.krHashCode(), file.krHashCode(), key)
} }
@JvmOverloads
public fun removeNamedGroup(groupNameHash: Int, file: Int, key: XteaKey = XteaKey.ZERO) {
require(file >= 0)
val entry = index.getNamed(groupNameHash) ?: return
if (isOverwriting(entry, file)) {
removeNamed(groupNameHash)
return
}
val unpacked = getUnpacked(entry, key)
unpacked.remove(file)
dirty = true
}
@JvmOverloads
public fun remove(group: String, file: Int, key: XteaKey = XteaKey.ZERO) {
removeNamedGroup(group.krHashCode(), file, key)
}
public override fun flush() { public override fun flush() {
if (!dirty) { if (!dirty) {
return return

@ -106,6 +106,15 @@ public class Cache private constructor(
return existsNamed(archive, group.krHashCode(), file.krHashCode()) return existsNamed(archive, group.krHashCode(), file.krHashCode())
} }
public fun existsNamedGroup(archive: Int, groupNameHash: Int, file: Int): Boolean {
checkArchive(archive)
return archives[archive]?.existsNamedGroup(groupNameHash, file) ?: false
}
public fun exists(archive: Int, group: String, file: Int): Boolean {
return existsNamedGroup(archive, group.krHashCode(), file)
}
public fun list(): Iterator<Int> { public fun list(): Iterator<Int> {
return archives.withIndex() return archives.withIndex()
.filter { it.value != null } .filter { it.value != null }
@ -132,25 +141,41 @@ public class Cache private constructor(
return listNamed(archive, group.krHashCode()) return listNamed(archive, group.krHashCode())
} }
@JvmOverloads
public fun read(archive: Int, group: Int, file: Int, key: XteaKey = XteaKey.ZERO): ByteBuf { public fun read(archive: Int, group: Int, file: Int, key: XteaKey = XteaKey.ZERO): ByteBuf {
checkArchive(archive) checkArchive(archive)
return archives[archive]?.read(group, file, key) ?: throw FileNotFoundException() return archives[archive]?.read(group, file, key) ?: throw FileNotFoundException()
} }
@JvmOverloads
public fun readNamed(archive: Int, groupNameHash: Int, fileNameHash: Int, key: XteaKey = XteaKey.ZERO): ByteBuf { public fun readNamed(archive: Int, groupNameHash: Int, fileNameHash: Int, key: XteaKey = XteaKey.ZERO): ByteBuf {
checkArchive(archive) checkArchive(archive)
return archives[archive]?.readNamed(groupNameHash, fileNameHash, key) ?: throw FileNotFoundException() return archives[archive]?.readNamed(groupNameHash, fileNameHash, key) ?: throw FileNotFoundException()
} }
@JvmOverloads
public fun read(archive: Int, group: String, file: String, key: XteaKey = XteaKey.ZERO): ByteBuf { public fun read(archive: Int, group: String, file: String, key: XteaKey = XteaKey.ZERO): ByteBuf {
return readNamed(archive, group.krHashCode(), file.krHashCode(), key) return readNamed(archive, group.krHashCode(), file.krHashCode(), key)
} }
@JvmOverloads
public fun readNamedGroup(archive: Int, groupNameHash: Int, file: Int, key: XteaKey = XteaKey.ZERO): ByteBuf {
checkArchive(archive)
return archives[archive]?.readNamedGroup(groupNameHash, file, key) ?: throw FileNotFoundException()
}
@JvmOverloads
public fun read(archive: Int, group: String, file: Int, key: XteaKey = XteaKey.ZERO): ByteBuf {
return readNamedGroup(archive, group.krHashCode(), file, key)
}
@JvmOverloads
public fun write(archive: Int, group: Int, file: Int, buf: ByteBuf, key: XteaKey = XteaKey.ZERO) { public fun write(archive: Int, group: Int, file: Int, buf: ByteBuf, key: XteaKey = XteaKey.ZERO) {
checkArchive(archive) checkArchive(archive)
createOrGetArchive(archive).write(group, file, buf, key) createOrGetArchive(archive).write(group, file, buf, key)
} }
@JvmOverloads
public fun writeNamed( public fun writeNamed(
archive: Int, archive: Int,
groupNameHash: Int, groupNameHash: Int,
@ -162,10 +187,22 @@ public class Cache private constructor(
createOrGetArchive(archive).writeNamed(groupNameHash, fileNameHash, buf, key) createOrGetArchive(archive).writeNamed(groupNameHash, fileNameHash, buf, key)
} }
@JvmOverloads
public fun write(archive: Int, group: String, file: String, buf: ByteBuf, key: XteaKey = XteaKey.ZERO) { public fun write(archive: Int, group: String, file: String, buf: ByteBuf, key: XteaKey = XteaKey.ZERO) {
writeNamed(archive, group.krHashCode(), file.krHashCode(), buf, key) writeNamed(archive, group.krHashCode(), file.krHashCode(), buf, key)
} }
@JvmOverloads
public fun writeNamedGroup(archive: Int, groupNameHash: Int, file: Int, buf: ByteBuf, key: XteaKey = XteaKey.ZERO) {
checkArchive(archive)
createOrGetArchive(archive).writeNamedGroup(groupNameHash, file, buf, key)
}
@JvmOverloads
public fun write(archive: Int, group: String, file: Int, buf: ByteBuf, key: XteaKey = XteaKey.ZERO) {
writeNamedGroup(archive, group.krHashCode(), file, buf, key)
}
public fun remove(archive: Int) { public fun remove(archive: Int) {
checkArchive(archive) checkArchive(archive)
@ -195,20 +232,34 @@ public class Cache private constructor(
return removeNamed(archive, group.krHashCode()) return removeNamed(archive, group.krHashCode())
} }
@JvmOverloads
public fun remove(archive: Int, group: Int, file: Int, key: XteaKey = XteaKey.ZERO) { public fun remove(archive: Int, group: Int, file: Int, key: XteaKey = XteaKey.ZERO) {
checkArchive(archive) checkArchive(archive)
archives[archive]?.remove(group, file, key) archives[archive]?.remove(group, file, key)
} }
@JvmOverloads
public fun removeNamed(archive: Int, groupNameHash: Int, fileNameHash: Int, key: XteaKey = XteaKey.ZERO) { public fun removeNamed(archive: Int, groupNameHash: Int, fileNameHash: Int, key: XteaKey = XteaKey.ZERO) {
checkArchive(archive) checkArchive(archive)
archives[archive]?.removeNamed(groupNameHash, fileNameHash, key) archives[archive]?.removeNamed(groupNameHash, fileNameHash, key)
} }
@JvmOverloads
public fun remove(archive: Int, group: String, file: String, key: XteaKey = XteaKey.ZERO) { public fun remove(archive: Int, group: String, file: String, key: XteaKey = XteaKey.ZERO) {
return removeNamed(archive, group.krHashCode(), file.krHashCode(), key) return removeNamed(archive, group.krHashCode(), file.krHashCode(), key)
} }
@JvmOverloads
public fun removeNamedGroup(archive: Int, groupNameHash: Int, file: Int, key: XteaKey = XteaKey.ZERO) {
checkArchive(archive)
archives[archive]?.removeNamedGroup(groupNameHash, file, key)
}
@JvmOverloads
public fun remove(archive: Int, group: String, file: Int, key: XteaKey = XteaKey.ZERO) {
removeNamedGroup(archive, group.krHashCode(), file, key)
}
/** /**
* Writes pending changes back to the underlying [Store]. * Writes pending changes back to the underlying [Store].
*/ */
@ -218,6 +269,8 @@ public class Cache private constructor(
for (archive in archives) { for (archive in archives) {
archive?.flush() archive?.flush()
} }
store.flush()
} }
/** /**
@ -240,6 +293,8 @@ public class Cache private constructor(
public companion object { public companion object {
public const val MAX_ARCHIVE: Int = 254 public const val MAX_ARCHIVE: Int = 254
@JvmOverloads
@JvmStatic
public fun open( public fun open(
root: Path, root: Path,
alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT,
@ -248,6 +303,8 @@ public class Cache private constructor(
return open(Store.open(root, alloc), alloc, unpackedCacheSize) return open(Store.open(root, alloc), alloc, unpackedCacheSize)
} }
@JvmOverloads
@JvmStatic
public fun open( public fun open(
store: Store, store: Store,
alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT,

@ -21,14 +21,20 @@ public class ChecksumTable(
} }
public companion object { public companion object {
@JvmStatic
public fun create(store: Store): ChecksumTable { public fun create(store: Store): ChecksumTable {
val table = ChecksumTable() val table = ChecksumTable()
var nextArchive = 0 var nextArchive = 0
for (archive in store.list(0)) { for (archive in store.list(0)) {
val entry = store.read(0, archive).use { buf -> val entry = try {
store.read(0, archive).use { buf ->
buf.crc32() buf.crc32()
} }
} catch (ex: StoreCorruptException) {
// see the equivalent comment in Js5MasterIndex::create
continue
}
for (i in nextArchive until archive) { for (i in nextArchive until archive) {
table.entries += 0 table.entries += 0
@ -41,6 +47,7 @@ public class ChecksumTable(
return table return table
} }
@JvmStatic
public fun read(buf: ByteBuf): ChecksumTable { public fun read(buf: ByteBuf): ChecksumTable {
val table = ChecksumTable() val table = ChecksumTable()

@ -450,7 +450,7 @@ public class DiskStore private constructor(
override fun flush() { override fun flush() {
data.flush() data.flush()
musicData?.close() musicData?.flush()
for (index in indexes) { for (index in indexes) {
index?.flush() index?.flush()
@ -500,6 +500,8 @@ public class DiskStore private constructor(
return root.resolve("main_file_cache.idx$archive") return root.resolve("main_file_cache.idx$archive")
} }
@JvmOverloads
@JvmStatic
public fun open(root: Path, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT): Store { public fun open(root: Path, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT): Store {
val js5DataPath = dataPath(root) val js5DataPath = dataPath(root)
val legacyDataPath = legacyDataPath(root) val legacyDataPath = legacyDataPath(root)
@ -548,6 +550,8 @@ public class DiskStore private constructor(
return DiskStore(root, data, musicData, archives, alloc, legacy) return DiskStore(root, data, musicData, archives, alloc, legacy)
} }
@JvmOverloads
@JvmStatic
public fun create( public fun create(
root: Path, root: Path,
alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT,

@ -92,7 +92,7 @@ public class FlatFileStore private constructor(
val path = groupPath(archive, group) val path = groupPath(archive, group)
Files.createDirectories(path.parent) Files.createDirectories(path.parent)
path.useAtomicOutputStream { output -> path.useAtomicOutputStream(sync = false) { output ->
buf.readBytes(output, buf.readableBytes()) buf.readBytes(output, buf.readableBytes())
} }
} }
@ -128,6 +128,8 @@ public class FlatFileStore private constructor(
private val GROUP_NAME = Regex("[0-9]+[.]dat") private val GROUP_NAME = Regex("[0-9]+[.]dat")
private const val GROUP_EXTENSION = ".dat" private const val GROUP_EXTENSION = ".dat"
@JvmOverloads
@JvmStatic
public fun open(root: Path, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT): Store { public fun open(root: Path, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT): Store {
if (!Files.isDirectory(root)) { if (!Files.isDirectory(root)) {
throw FileNotFoundException() throw FileNotFoundException()
@ -136,6 +138,8 @@ public class FlatFileStore private constructor(
return FlatFileStore(root, alloc) return FlatFileStore(root, alloc)
} }
@JvmOverloads
@JvmStatic
public fun create(root: Path, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT): Store { public fun create(root: Path, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT): Store {
Files.createDirectories(root) Files.createDirectories(root)
return FlatFileStore(root, alloc) return FlatFileStore(root, alloc)

@ -129,6 +129,7 @@ public class JagArchive : Closeable {
* @param alloc the allocator. * @param alloc the allocator.
* @return the compressed archive. * @return the compressed archive.
*/ */
@JvmOverloads
public fun pack(compressedArchive: Boolean, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT): ByteBuf { public fun pack(compressedArchive: Boolean, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT): ByteBuf {
alloc.buffer().use { output -> alloc.buffer().use { output ->
alloc.buffer().use { uncompressedArchiveBuf -> alloc.buffer().use { uncompressedArchiveBuf ->
@ -189,6 +190,7 @@ public class JagArchive : Closeable {
* @param alloc the allocator. * @param alloc the allocator.
* @return the compressed archive. * @return the compressed archive.
*/ */
@JvmOverloads
public fun packBest(alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT): ByteBuf { public fun packBest(alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT): ByteBuf {
pack(true, alloc).use { compressedArchive -> pack(true, alloc).use { compressedArchive ->
pack(false, alloc).use { compressedEntries -> pack(false, alloc).use { compressedEntries ->
@ -228,6 +230,7 @@ public class JagArchive : Closeable {
* @param buf the compressed archive. * @param buf the compressed archive.
* @return the unpacked archive. * @return the unpacked archive.
*/ */
@JvmStatic
public fun unpack(buf: ByteBuf): JagArchive { public fun unpack(buf: ByteBuf): JagArchive {
val archive = JagArchive() val archive = JagArchive()

@ -18,6 +18,7 @@ public object Js5Compression {
private const val LZMA_PB_MAX = 4 private const val LZMA_PB_MAX = 4
private const val LZMA_PRESET_DICT_SIZE_MAX = 1 shl 26 private const val LZMA_PRESET_DICT_SIZE_MAX = 1 shl 26
@JvmOverloads
public fun compress(input: ByteBuf, type: Js5CompressionType, key: XteaKey = XteaKey.ZERO): ByteBuf { public fun compress(input: ByteBuf, type: Js5CompressionType, key: XteaKey = XteaKey.ZERO): ByteBuf {
input.alloc().buffer().use { output -> input.alloc().buffer().use { output ->
output.writeByte(type.ordinal) output.writeByte(type.ordinal)
@ -107,7 +108,12 @@ public object Js5Compression {
} }
} }
@JvmOverloads
public fun uncompress(input: ByteBuf, key: XteaKey = XteaKey.ZERO): ByteBuf { public fun uncompress(input: ByteBuf, key: XteaKey = XteaKey.ZERO): ByteBuf {
if (input.readableBytes() < 5) {
throw IOException("Missing header")
}
val typeId = input.readUnsignedByte().toInt() val typeId = input.readUnsignedByte().toInt()
val type = Js5CompressionType.fromOrdinal(typeId) val type = Js5CompressionType.fromOrdinal(typeId)
?: throw IOException("Invalid compression type: $typeId") ?: throw IOException("Invalid compression type: $typeId")
@ -141,8 +147,8 @@ public object Js5Compression {
throw IOException("Uncompressed length is negative: $uncompressedLen") throw IOException("Uncompressed length is negative: $uncompressedLen")
} }
plaintext.alloc().buffer(uncompressedLen, uncompressedLen).use { output ->
type.createInputStream(ByteBufInputStream(plaintext, len), uncompressedLen).use { inputStream -> type.createInputStream(ByteBufInputStream(plaintext, len), uncompressedLen).use { inputStream ->
plaintext.alloc().buffer(uncompressedLen, uncompressedLen).use { output ->
var remaining = uncompressedLen var remaining = uncompressedLen
while (remaining > 0) { while (remaining > 0) {
val n = output.writeBytes(inputStream, remaining) val n = output.writeBytes(inputStream, remaining)
@ -155,18 +161,22 @@ public object Js5Compression {
if (inputStream.read() != -1) { if (inputStream.read() != -1) {
throw IOException("Uncompressed data overflow") throw IOException("Uncompressed data overflow")
} }
}
return output.retain() return output.retain()
} }
} }
} }
}
public fun uncompressUnlessEncrypted(input: ByteBuf): ByteBuf? { public fun uncompressUnlessEncrypted(input: ByteBuf): ByteBuf? {
return uncompressIfKeyValid(input, XteaKey.ZERO) return uncompressIfKeyValid(input, XteaKey.ZERO)
} }
public fun uncompressIfKeyValid(input: ByteBuf, key: XteaKey): ByteBuf? { public fun uncompressIfKeyValid(input: ByteBuf, key: XteaKey): ByteBuf? {
if (input.readableBytes() < 5) {
throw IOException("Missing header")
}
val typeId = input.readUnsignedByte().toInt() val typeId = input.readUnsignedByte().toInt()
val type = Js5CompressionType.fromOrdinal(typeId) val type = Js5CompressionType.fromOrdinal(typeId)
?: throw IOException("Invalid compression type: $typeId") ?: throw IOException("Invalid compression type: $typeId")
@ -245,6 +255,7 @@ public object Js5Compression {
return null return null
} }
} }
Js5CompressionType.GZIP -> { Js5CompressionType.GZIP -> {
val magic = plaintext.readUnsignedShort() val magic = plaintext.readUnsignedShort()
if (magic != GZIP_MAGIC) { if (magic != GZIP_MAGIC) {
@ -259,6 +270,7 @@ public object Js5Compression {
return null return null
} }
} }
Js5CompressionType.LZMA -> { Js5CompressionType.LZMA -> {
val properties = plaintext.readUnsignedByte() val properties = plaintext.readUnsignedByte()
@ -307,6 +319,8 @@ public object Js5Compression {
val uncompressedLen = plaintext.readInt() val uncompressedLen = plaintext.readInt()
check(uncompressedLen >= 0) check(uncompressedLen >= 0)
try {
type.createInputStream(ByteBufInputStream(plaintext, len), uncompressedLen).use { inputStream ->
/** /**
* We don't pass uncompressedLen to the buffer here: in some cases, * We don't pass uncompressedLen to the buffer here: in some cases,
* an incorrect key can produce a valid header (particularly for * an incorrect key can produce a valid header (particularly for
@ -320,8 +334,6 @@ public object Js5Compression {
* We therefore allow the buffer to grow dynamically. * We therefore allow the buffer to grow dynamically.
*/ */
plaintext.alloc().buffer().use { output -> plaintext.alloc().buffer().use { output ->
try {
type.createInputStream(ByteBufInputStream(plaintext, len), uncompressedLen).use { inputStream ->
var remaining = uncompressedLen var remaining = uncompressedLen
while (remaining > 0) { while (remaining > 0) {
val n = output.writeBytes(inputStream, remaining) val n = output.writeBytes(inputStream, remaining)
@ -336,13 +348,13 @@ public object Js5Compression {
// uncompressed data overflow // uncompressed data overflow
return null return null
} }
return output.retain()
}
} }
} catch (ex: IOException) { } catch (ex: IOException) {
return null return null
} }
return output.retain()
}
} }
} }
@ -358,6 +370,10 @@ public object Js5Compression {
} }
public fun isEmptyLoc(buf: ByteBuf): Boolean { public fun isEmptyLoc(buf: ByteBuf): Boolean {
if (buf.readableBytes() < 5) {
throw IOException("Missing header")
}
val typeId = buf.readUnsignedByte().toInt() val typeId = buf.readUnsignedByte().toInt()
val type = Js5CompressionType.fromOrdinal(typeId) val type = Js5CompressionType.fromOrdinal(typeId)
?: throw IOException("Invalid compression type: $typeId") ?: throw IOException("Invalid compression type: $typeId")

@ -41,6 +41,7 @@ public enum class Js5CompressionType {
public companion object { public companion object {
private val values = values() private val values = values()
@JvmStatic
public fun fromOrdinal(ordinal: Int): Js5CompressionType? { public fun fromOrdinal(ordinal: Int): Js5CompressionType? {
return if (ordinal >= 0 && ordinal < values.size) { return if (ordinal >= 0 && ordinal < values.size) {
values[ordinal] values[ordinal]

@ -102,7 +102,7 @@ public class Js5Index(
public class MutableFile internal constructor( public class MutableFile internal constructor(
parent: MutableNamedEntryCollection<MutableFile>, parent: MutableNamedEntryCollection<MutableFile>,
override val id: Int, override val id: Int
) : MutableNamedEntry, File { ) : MutableNamedEntry, File {
private var parent: MutableNamedEntryCollection<MutableFile>? = parent private var parent: MutableNamedEntryCollection<MutableFile>? = parent
@ -280,6 +280,7 @@ public class Js5Index(
private const val FLAG_LENGTHS = 0x04 private const val FLAG_LENGTHS = 0x04
private const val FLAG_UNCOMPRESSED_CHECKSUMS = 0x08 private const val FLAG_UNCOMPRESSED_CHECKSUMS = 0x08
@JvmStatic
public fun read(buf: ByteBuf): Js5Index { public fun read(buf: ByteBuf): Js5Index {
val number = buf.readUnsignedByte().toInt() val number = buf.readUnsignedByte().toInt()
val protocol = Js5Protocol.fromId(number) val protocol = Js5Protocol.fromId(number)

@ -66,6 +66,7 @@ public data class Js5MasterIndex(
} }
} }
@JvmOverloads
public fun write(buf: ByteBuf, key: RSAKeyParameters? = null) { public fun write(buf: ByteBuf, key: RSAKeyParameters? = null) {
val start = buf.writerIndex() val start = buf.writerIndex()
@ -117,6 +118,7 @@ public data class Js5MasterIndex(
public companion object { public companion object {
private const val SIGNATURE_LENGTH = Whirlpool.DIGESTBYTES + 1 private const val SIGNATURE_LENGTH = Whirlpool.DIGESTBYTES + 1
@JvmStatic
public fun create(store: Store): Js5MasterIndex { public fun create(store: Store): Js5MasterIndex {
val masterIndex = Js5MasterIndex(MasterIndexFormat.ORIGINAL) val masterIndex = Js5MasterIndex(MasterIndexFormat.ORIGINAL)
@ -179,10 +181,13 @@ public data class Js5MasterIndex(
return masterIndex return masterIndex
} }
@JvmOverloads
@JvmStatic
public fun read(buf: ByteBuf, format: MasterIndexFormat, key: RSAKeyParameters? = null): Js5MasterIndex { public fun read(buf: ByteBuf, format: MasterIndexFormat, key: RSAKeyParameters? = null): Js5MasterIndex {
return read(buf, format, key, true) return read(buf, format, key, true)
} }
@JvmStatic
public fun readUnverified(buf: ByteBuf, format: MasterIndexFormat): Js5MasterIndex { public fun readUnverified(buf: ByteBuf, format: MasterIndexFormat): Js5MasterIndex {
return read(buf, format, null, false) return read(buf, format, null, false)
} }
@ -205,12 +210,14 @@ public data class Js5MasterIndex(
} }
len / 4 len / 4
} }
MasterIndexFormat.VERSIONED -> { MasterIndexFormat.VERSIONED -> {
require(len % 8 == 0) { require(len % 8 == 0) {
"Length is not a multiple of 8 bytes" "Length is not a multiple of 8 bytes"
} }
len / 8 len / 8
} }
else -> { else -> {
buf.readUnsignedByte().toInt() buf.readUnsignedByte().toInt()
} }

@ -23,7 +23,7 @@ public class Js5Pack private constructor(
index: Js5Index, index: Js5Index,
unpackedCacheSize: Int, unpackedCacheSize: Int,
private var packedIndex: ByteBuf, private var packedIndex: ByteBuf,
private val packed: Int2ObjectSortedMap<ByteBuf>, private val packed: Int2ObjectSortedMap<ByteBuf>
) : Archive(alloc, index, 0, UnpackedCache(unpackedCacheSize)), Closeable { ) : Archive(alloc, index, 0, UnpackedCache(unpackedCacheSize)), Closeable {
override fun packedExists(group: Int): Boolean { override fun packedExists(group: Int): Boolean {
return packed.containsKey(group) return packed.containsKey(group)
@ -91,6 +91,8 @@ public class Js5Pack private constructor(
} }
public companion object { public companion object {
@JvmOverloads
@JvmStatic
public fun create( public fun create(
alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT,
unpackedCacheSize: Int = UnpackedCache.DEFAULT_CAPACITY unpackedCacheSize: Int = UnpackedCache.DEFAULT_CAPACITY
@ -107,6 +109,8 @@ public class Js5Pack private constructor(
} }
} }
@JvmOverloads
@JvmStatic
public fun read( public fun read(
path: Path, path: Path,
alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT,
@ -117,6 +121,8 @@ public class Js5Pack private constructor(
} }
} }
@JvmOverloads
@JvmStatic
public fun read( public fun read(
input: InputStream, input: InputStream,
alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT, alloc: ByteBufAllocator = ByteBufAllocator.DEFAULT,

@ -12,6 +12,7 @@ public enum class Js5Protocol {
private const val OFFSET = 5 private const val OFFSET = 5
private val values = values() private val values = values()
@JvmStatic
public fun fromId(id: Int): Js5Protocol? { public fun fromId(id: Int): Js5Protocol? {
val ordinal = id - OFFSET val ordinal = id - OFFSET
return if (ordinal >= 0 && ordinal < values.size) { return if (ordinal >= 0 && ordinal < values.size) {

@ -251,6 +251,7 @@ public abstract class MutableNamedEntryCollection<T : MutableNamedEntry>(
newSet.add(id) newSet.add(id)
nameHashTable[newNameHash] = newSet nameHashTable[newNameHash] = newSet
} }
else -> set.add(id) else -> set.add(id)
} }

@ -0,0 +1,89 @@
package org.openrs2.cache
import io.netty.buffer.Unpooled
import org.openrs2.buffer.crc32
import org.openrs2.buffer.use
import org.sqlite.SQLiteDataSource
import java.nio.file.Files
import java.nio.file.Path
import java.sql.Connection
public object OpenNxtStore {
public fun unpack(input: Path, output: Store) {
output.create(Store.ARCHIVESET)
for (archive in 0..Store.MAX_ARCHIVE) {
val path = input.resolve("js5-$archive.jcache")
if (!Files.exists(path)) {
continue
}
val dataSource = SQLiteDataSource()
dataSource.url = "jdbc:sqlite:$path"
dataSource.connection.use { connection ->
unpackArchive(connection, archive, output)
}
}
}
private fun unpackArchive(connection: Connection, archive: Int, output: Store) {
connection.prepareStatement(
"""
SELECT data, crc
FROM cache_index
WHERE key = 1
""".trimIndent()
).use { stmt ->
stmt.executeQuery().use { rows ->
if (rows.next()) {
val checksum = rows.getInt(2)
Unpooled.wrappedBuffer(rows.getBytes(1)).use { buf ->
val actualChecksum = buf.crc32()
if (actualChecksum != checksum) {
throw StoreCorruptException(
"Js5Index corrupt (expected checksum $checksum, actual checksum $actualChecksum)"
)
}
output.write(Store.ARCHIVESET, archive, buf)
}
}
}
}
connection.prepareStatement(
"""
SELECT key, data, crc, version
FROM cache
""".trimIndent()
).use { stmt ->
stmt.executeQuery().use { rows ->
while (rows.next()) {
val group = rows.getInt(1)
val checksum = rows.getInt(3)
val version = rows.getInt(4) and 0xFFFF
Unpooled.wrappedBuffer(rows.getBytes(2)).use { buf ->
val actualVersion = VersionTrailer.peek(buf)
if (actualVersion != version) {
throw StoreCorruptException(
"Group corrupt (expected version $version, actual version $actualVersion)"
)
}
val actualChecksum = buf.slice(buf.readerIndex(), buf.writerIndex() - 2).crc32()
if (actualChecksum != checksum) {
throw StoreCorruptException(
"Group corrupt (expected checksum $checksum, actual checksum $actualChecksum)"
)
}
output.write(archive, group, buf)
}
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save