From c91080aba7fc5d98a8fa89c0456974eb7015e4c9 Mon Sep 17 00:00:00 2001 From: ml Date: Sat, 26 Sep 2026 08:53:08 +0200 Subject: [PATCH] Adding modding folder with a hacked build. --- .gitignore | 3 + AGENTS.md | 1 + modding/Makefile | 30 + modding/README.md | 84 ++ modding/hacked-build-image.sh | 35 + modding/hacked-build-image/Dockerfile | 64 ++ modding/hacked-build-image/VERSION | 1 + modding/hacked-build-image/build-hacked.sh | 77 ++ .../hacked-build-image/docker-entrypoint.sh | 35 + modding/hacked-build-run-image.sh | 46 + modding/hacked-build-upload-image.sh | 41 + modding/hacked/.gitattributes | 3 + modding/hacked/.gitignore | 5 + modding/hacked/.golangci.yml | 84 ++ modding/hacked/CONTRIBUTING.md | 38 + modding/hacked/LICENSE | 27 + modding/hacked/README.md | 28 + .../_resources/build/win/hacked.exe.manifest | 20 + .../hacked/_resources/build/win/hacked.ico | Bin 0 -> 59591 bytes modding/hacked/_resources/build/win/hacked.rc | 3 + modding/hacked/_resources/dist/README.md | 17 + modding/hacked/build.sh | 76 ++ modding/hacked/crash/Handler.go | 4 + modding/hacked/crash/Handler_windows.go | 42 + modding/hacked/editor/Application.go | 945 +++++++++++++++ modding/hacked/editor/ClipboardAdapter.go | 15 + modding/hacked/editor/about/LicensesView.go | 66 ++ modding/hacked/editor/about/View.go | 70 ++ modding/hacked/editor/about/ViewModel.go | 9 + modding/hacked/editor/about/licenses.go | 227 ++++ .../editor/animations/SetAnimationCommand.go | 41 + modding/hacked/editor/animations/View.go | 338 ++++++ modding/hacked/editor/animations/ViewModel.go | 20 + .../archives/EditBooleanVariableDialog.go | 125 ++ .../archives/EditIntegerVariableDialog.go | 214 ++++ .../editor/archives/SetArchiveDataCommand.go | 45 + modding/hacked/editor/archives/View.go | 934 +++++++++++++++ modding/hacked/editor/archives/ViewModel.go | 16 + .../hacked/editor/bitmaps/SetBitmapCommand.go | 32 + modding/hacked/editor/bitmaps/View.go | 271 +++++ modding/hacked/editor/bitmaps/ViewModel.go | 19 + modding/hacked/editor/doc.go | 4 + modding/hacked/editor/external/Clipboard.go | 9 + modding/hacked/editor/external/Export.go | 71 ++ .../editor/external/ExportStartState.go | 32 + .../editor/external/ExportWaitingState.go | 81 ++ modding/hacked/editor/external/FileDialog.go | 137 +++ modding/hacked/editor/external/Import.go | 117 ++ .../editor/external/ImportStartState.go | 34 + .../editor/external/ImportWaitingState.go | 83 ++ modding/hacked/editor/external/TypeInfo.go | 7 + .../hacked/editor/graphics/BitmapTexture.go | 91 ++ modding/hacked/editor/graphics/FrameCache.go | 76 ++ .../hacked/editor/graphics/PaletteCache.go | 71 ++ .../hacked/editor/graphics/PaletteTexture.go | 70 ++ .../hacked/editor/graphics/TextureCache.go | 94 ++ .../hacked/editor/levels/BackgroundGrid.go | 162 +++ modding/hacked/editor/levels/ColorDisplay.go | 34 + modding/hacked/editor/levels/Constants.go | 6 + modding/hacked/editor/levels/ControlView.go | 409 +++++++ .../hacked/editor/levels/ControlViewModel.go | 16 + modding/hacked/editor/levels/Formatters.go | 39 + modding/hacked/editor/levels/Highlighter.go | 124 ++ modding/hacked/editor/levels/HoverItems.go | 135 +++ modding/hacked/editor/levels/LimitedCamera.go | 98 ++ modding/hacked/editor/levels/MapColors.go | 179 +++ modding/hacked/editor/levels/MapDisplay.go | 587 ++++++++++ modding/hacked/editor/levels/MapGrid.go | 312 +++++ modding/hacked/editor/levels/MapIcons.go | 198 ++++ modding/hacked/editor/levels/MapPosition.go | 19 + modding/hacked/editor/levels/MapTextures.go | 255 +++++ modding/hacked/editor/levels/ObjectsView.go | 1020 +++++++++++++++++ .../hacked/editor/levels/ObjectsViewModel.go | 10 + .../hacked/editor/levels/TextureDisplay.go | 64 ++ modding/hacked/editor/levels/TilesView.go | 551 +++++++++ .../hacked/editor/levels/TilesViewModel.go | 18 + modding/hacked/editor/levels/hazards.go | 64 ++ .../editor/messages/SetMessageDataCommand.go | 52 + modding/hacked/editor/messages/View.go | 488 ++++++++ modding/hacked/editor/messages/ViewModel.go | 21 + .../editor/movies/CompressingStartState.go | 36 + .../editor/movies/CompressingWaitingState.go | 45 + .../hacked/editor/movies/CompressionTask.go | 65 ++ modding/hacked/editor/movies/View.go | 615 ++++++++++ modding/hacked/editor/movies/ViewModel.go | 26 + .../editor/objects/SetObjectBitmapCommand.go | 35 + .../objects/SetObjectPropertiesCommand.go | 33 + .../editor/objects/SetObjectTextCommand.go | 35 + modding/hacked/editor/objects/View.go | 710 ++++++++++++ modding/hacked/editor/objects/ViewModel.go | 19 + .../project/AddManifestEntryStartState.go | 23 + .../project/AddManifestEntryWaitingState.go | 61 + .../editor/project/LoadModStartState.go | 23 + .../editor/project/LoadModWaitingState.go | 62 + .../editor/project/SaveModAsStartState.go | 23 + .../editor/project/SaveModAsWaitingState.go | 82 ++ .../editor/project/SaveModFailedState.go | 28 + modding/hacked/editor/project/View.go | 246 ++++ modding/hacked/editor/project/ViewModel.go | 14 + modding/hacked/editor/render/Context.go | 15 + modding/hacked/editor/render/FrameImage.go | 39 + .../hacked/editor/render/OrientationView.go | 234 ++++ modding/hacked/editor/render/TextureID.go | 34 + modding/hacked/editor/render/TextureImage.go | 40 + .../hacked/editor/render/TextureRenderer.go | 88 ++ .../hacked/editor/render/TextureSelector.go | 44 + modding/hacked/editor/sounds/View.go | 143 +++ modding/hacked/editor/sounds/ViewModel.go | 18 + modding/hacked/editor/texts/View.go | 198 ++++ modding/hacked/editor/texts/ViewModel.go | 18 + .../textures/SetTextureBitmapCommand.go | 50 + .../textures/SetTexturePropertiesCommand.go | 31 + .../editor/textures/SetTextureTextCommand.go | 31 + modding/hacked/editor/textures/View.go | 402 +++++++ modding/hacked/editor/textures/ViewModel.go | 20 + .../editor/values/InterpreterControls.go | 167 +++ .../values/ObjectTypeControlRenderer.go | 71 ++ modding/hacked/editor/values/Properties.go | 156 +++ modding/hacked/editor/values/Unifier.go | 107 ++ modding/hacked/editor/values/Unifier_test.go | 84 ++ modding/hacked/go.mod | 26 + modding/hacked/go.sum | 52 + modding/hacked/main.go | 86 ++ modding/hacked/ss1/StringError.go | 10 + .../hacked/ss1/content/archive/Constants.go | 6 + .../hacked/ss1/content/archive/GameState.go | 825 +++++++++++++ .../hacked/ss1/content/archive/IsSavegame.go | 28 + .../ss1/content/archive/IsSavegame_test.go | 69 ++ .../hacked/ss1/content/archive/Variables.go | 253 ++++ modding/hacked/ss1/content/archive/doc.go | 2 + .../ss1/content/archive/level/BaseInfo.go | 43 + .../content/archive/level/BaseInfo_test.go | 26 + .../content/archive/level/BlockPuzzleState.go | 75 ++ .../archive/level/BlockPuzzleState_test.go | 131 +++ .../ss1/content/archive/level/Constants.go | 20 + .../ss1/content/archive/level/Coordinate.go | 22 + .../archive/level/CyberspaceFlightPull.go | 92 ++ .../ss1/content/archive/level/Direction.go | 41 + .../content/archive/level/Direction_test.go | 40 + .../content/archive/level/EmptyLevelData.go | 94 ++ .../archive/level/EmptyLevelData_test.go | 90 ++ .../ss1/content/archive/level/Errors.go | 10 + .../content/archive/level/FloorCeilingInfo.go | 85 ++ .../archive/level/FloorCeilingInfo_test.go | 41 + .../content/archive/level/HeightSemaphores.go | 4 + .../ss1/content/archive/level/HeightShift.go | 39 + .../ss1/content/archive/level/HeightUnit.go | 4 + .../hacked/ss1/content/archive/level/Level.go | 581 ++++++++++ .../content/archive/level/LightDeltaInfo.go | 30 + .../content/archive/level/LoopConfigEntry.go | 8 + .../ss1/content/archive/level/MapNotes.go | 9 + .../content/archive/level/ObjectClassInfo.go | 34 + .../content/archive/level/ObjectClassTable.go | 156 +++ .../archive/level/ObjectClassTable_test.go | 144 +++ .../level/ObjectCrossReferenceTable.go | 103 ++ .../level/ObjectCrossReferenceTable_test.go | 114 ++ .../ss1/content/archive/level/ObjectID.go | 5 + .../content/archive/level/ObjectMainTable.go | 160 +++ .../archive/level/ObjectMainTable_test.go | 116 ++ .../ss1/content/archive/level/Parameters.go | 29 + .../content/archive/level/Parameters_test.go | 28 + .../ss1/content/archive/level/RotationUnit.go | 9 + .../content/archive/level/SchedulerInfo.go | 29 + .../ss1/content/archive/level/Surveillance.go | 6 + .../archive/level/TextureAnimationEntry.go | 55 + .../ss1/content/archive/level/TextureIndex.go | 17 + .../ss1/content/archive/level/TileFlag.go | 227 ++++ .../content/archive/level/TileHeightUnit.go | 10 + .../ss1/content/archive/level/TileMap.go | 93 ++ .../ss1/content/archive/level/TileMap_test.go | 16 + .../ss1/content/archive/level/TilePosition.go | 62 + .../archive/level/TilePosition_test.go | 28 + .../content/archive/level/TileSlopeControl.go | 59 + .../content/archive/level/TileTextureInfo.go | 72 ++ .../ss1/content/archive/level/TileType.go | 66 ++ .../ss1/content/archive/level/TileTypeInfo.go | 53 + .../content/archive/level/TileType_test.go | 49 + .../ss1/content/archive/level/Unknowns.go | 16 + .../ss1/content/archive/level/WallHeights.go | 119 ++ .../content/archive/level/WallHeights_test.go | 208 ++++ .../archive/level/WallTexturePattern.go | 40 + .../archive/level/lvlids/Identifier.go | 45 + .../content/archive/level/lvlobj/Animating.go | 7 + .../content/archive/level/lvlobj/BigStuff.go | 122 ++ .../archive/level/lvlobj/Containers.go | 36 + .../content/archive/level/lvlobj/Critters.go | 40 + .../ss1/content/archive/level/lvlobj/Doors.go | 38 + .../content/archive/level/lvlobj/Fixtures.go | 217 ++++ .../archive/level/lvlobj/ForCyberspace.go | 18 + .../archive/level/lvlobj/ForRealWorld.go | 18 + .../archive/level/lvlobj/ForceColors.go | 17 + .../content/archive/level/lvlobj/Grenades.go | 21 + .../ss1/content/archive/level/lvlobj/Guns.go | 44 + .../content/archive/level/lvlobj/Hardware.go | 8 + .../level/lvlobj/InterpreterFactory.go | 9 + .../level/lvlobj/InterpreterRetriever.go | 53 + .../content/archive/level/lvlobj/Physics.go | 7 + .../archive/level/lvlobj/SmallStuff.go | 128 +++ .../content/archive/level/lvlobj/Software.go | 73 ++ .../ss1/content/archive/level/lvlobj/Traps.go | 82 ++ .../archive/level/lvlobj/actions/Actions.go | 358 ++++++ .../level/lvlobj/conditions/GameVariable.go | 15 + .../level/lvlobj/conditions/Objects.go | 21 + .../content/archive/level/lvlobj/lvlobj.go | 77 ++ modding/hacked/ss1/content/audio/L8.go | 20 + .../hacked/ss1/content/audio/voc/BlockType.go | 8 + modding/hacked/ss1/content/audio/voc/Load.go | 113 ++ .../hacked/ss1/content/audio/voc/Load_test.go | 107 ++ modding/hacked/ss1/content/audio/voc/Save.go | 64 ++ .../hacked/ss1/content/audio/voc/Utility.go | 22 + .../ss1/content/audio/voc/Utility_test.go | 23 + modding/hacked/ss1/content/audio/wav/Load.go | 32 + .../hacked/ss1/content/audio/wav/Load_test.go | 65 ++ modding/hacked/ss1/content/audio/wav/Save.go | 35 + .../hacked/ss1/content/audio/wav/Save_test.go | 37 + .../ss1/content/audio/wav/WaveLoader.go | 133 +++ .../ss1/content/audio/wav/formatHeader.go | 33 + modding/hacked/ss1/content/audio/wav/riff.go | 19 + .../hacked/ss1/content/bitmap/Animation.go | 98 ++ .../ss1/content/bitmap/AnimationCache.go | 58 + .../ss1/content/bitmap/AnimationCache_test.go | 176 +++ .../ss1/content/bitmap/Animation_test.go | 130 +++ modding/hacked/ss1/content/bitmap/Bitmap.go | 101 ++ .../hacked/ss1/content/bitmap/Bitmap_test.go | 114 ++ .../hacked/ss1/content/bitmap/Bitmapper.go | 105 ++ .../hacked/ss1/content/bitmap/Constants.go | 5 + modding/hacked/ss1/content/bitmap/Header.go | 43 + modding/hacked/ss1/content/bitmap/Palette.go | 65 ++ .../hacked/ss1/content/bitmap/PaletteCache.go | 67 ++ .../ss1/content/bitmap/PaletteCache_test.go | 169 +++ modding/hacked/ss1/content/doc.go | 4 + .../ss1/content/interpreters/Description.go | 86 ++ .../content/interpreters/Description_test.go | 87 ++ .../hacked/ss1/content/interpreters/Entry.go | 16 + .../ss1/content/interpreters/Instance.go | 138 +++ .../ss1/content/interpreters/Instance_test.go | 146 +++ .../ss1/content/interpreters/Refinement.go | 8 + .../ss1/content/interpreters/Simplifier.go | 193 ++++ .../content/interpreters/Simplifier_test.go | 42 + modding/hacked/ss1/content/movie/Audio.go | 53 + modding/hacked/ss1/content/movie/Cache.go | 132 +++ .../ss1/content/movie/ContainSoundData.go | 18 + modding/hacked/ss1/content/movie/Container.go | 20 + modding/hacked/ss1/content/movie/Read.go | 211 ++++ modding/hacked/ss1/content/movie/Read_test.go | 87 ++ modding/hacked/ss1/content/movie/Scene.go | 20 + modding/hacked/ss1/content/movie/Subtitles.go | 103 ++ modding/hacked/ss1/content/movie/Video.go | 208 ++++ modding/hacked/ss1/content/movie/Write.go | 143 +++ .../hacked/ss1/content/movie/Write_test.go | 50 + .../internal/compression/BitstreamReader.go | 87 ++ .../compression/BitstreamReader_test.go | 181 +++ .../internal/compression/BitstreamWriter.go | 34 + .../compression/BitstreamWriter_test.go | 99 ++ .../internal/compression/Compression_test.go | 241 ++++ .../movie/internal/compression/Constants.go | 10 + .../movie/internal/compression/ControlType.go | 18 + .../movie/internal/compression/ControlWord.go | 47 + .../compression/ControlWordSequencer.go | 198 ++++ .../compression/ControlWordSequencer_test.go | 249 ++++ .../internal/compression/ControlWord_test.go | 57 + .../movie/internal/compression/Errors.go | 45 + .../internal/compression/FrameDecoder.go | 123 ++ .../compression/FrameDecoderBuilder.go | 41 + .../movie/internal/compression/Maskstream.go | 59 + .../internal/compression/Maskstream_test.go | 62 + .../internal/compression/PackControlWords.go | 29 + .../compression/PackControlWords_test.go | 72 ++ .../internal/compression/PackedControlWord.go | 14 + .../compression/PackedControlWord_test.go | 21 + .../compression/PaletteLookupGenerator.go | 176 +++ .../internal/compression/SceneEncoder.go | 180 +++ .../compression/StandardTileColorer.go | 22 + .../compression/StandardTileColorer_test.go | 104 ++ .../internal/compression/TileColorFunction.go | 6 + .../internal/compression/TilePaletteKey.go | 70 ++ .../compression/TilePaletteKey_test.go | 94 ++ .../compression/UnpackControlWords.go | 46 + .../compression/UnpackControlWords_test.go | 55 + .../_testdata/deth/scene00/controlDict.bin | Bin 0 -> 69488 bytes .../deth/scene00/frame00_bitstream.bin | Bin 0 -> 10871 bytes .../deth/scene00/frame00_maskstream.bin | Bin 0 -> 22868 bytes .../deth/scene00/frame01_bitstream.bin | Bin 0 -> 4162 bytes .../deth/scene00/frame01_maskstream.bin | Bin 0 -> 11678 bytes .../deth/scene00/frame02_bitstream.bin | Bin 0 -> 4280 bytes .../deth/scene00/frame02_maskstream.bin | Bin 0 -> 11782 bytes .../deth/scene00/frame03_bitstream.bin | Bin 0 -> 4146 bytes .../deth/scene00/frame03_maskstream.bin | Bin 0 -> 11314 bytes .../deth/scene00/frame04_bitstream.bin | Bin 0 -> 4262 bytes .../deth/scene00/frame04_maskstream.bin | Bin 0 -> 11760 bytes .../deth/scene00/frame05_bitstream.bin | Bin 0 -> 4286 bytes .../deth/scene00/frame05_maskstream.bin | Bin 0 -> 11814 bytes .../deth/scene00/frame06_bitstream.bin | Bin 0 -> 4238 bytes .../deth/scene00/frame06_maskstream.bin | Bin 0 -> 11986 bytes .../deth/scene00/frame07_bitstream.bin | Bin 0 -> 4159 bytes .../deth/scene00/frame07_maskstream.bin | Bin 0 -> 11842 bytes .../deth/scene00/frame08_bitstream.bin | Bin 0 -> 4132 bytes .../deth/scene00/frame08_maskstream.bin | Bin 0 -> 11602 bytes .../deth/scene00/frame09_bitstream.bin | Bin 0 -> 4213 bytes .../deth/scene00/frame09_maskstream.bin | Bin 0 -> 12132 bytes .../deth/scene00/frame10_bitstream.bin | Bin 0 -> 4099 bytes .../deth/scene00/frame10_maskstream.bin | Bin 0 -> 11870 bytes .../deth/scene00/frame11_bitstream.bin | Bin 0 -> 4201 bytes .../deth/scene00/frame11_maskstream.bin | Bin 0 -> 12106 bytes .../deth/scene00/frame12_bitstream.bin | Bin 0 -> 4166 bytes .../deth/scene00/frame12_maskstream.bin | Bin 0 -> 12070 bytes .../deth/scene00/frame13_bitstream.bin | Bin 0 -> 4275 bytes .../deth/scene00/frame13_maskstream.bin | Bin 0 -> 12550 bytes .../deth/scene00/frame14_bitstream.bin | Bin 0 -> 4269 bytes .../deth/scene00/frame14_maskstream.bin | Bin 0 -> 12504 bytes .../deth/scene00/frame15_bitstream.bin | Bin 0 -> 4225 bytes .../deth/scene00/frame15_maskstream.bin | Bin 0 -> 12296 bytes .../deth/scene00/frame16_bitstream.bin | Bin 0 -> 4303 bytes .../deth/scene00/frame16_maskstream.bin | Bin 0 -> 12592 bytes .../deth/scene00/frame17_bitstream.bin | Bin 0 -> 4331 bytes .../deth/scene00/frame17_maskstream.bin | Bin 0 -> 12756 bytes .../deth/scene00/frame18_bitstream.bin | Bin 0 -> 4325 bytes .../deth/scene00/frame18_maskstream.bin | Bin 0 -> 12386 bytes .../deth/scene00/frame19_bitstream.bin | Bin 0 -> 4265 bytes .../deth/scene00/frame19_maskstream.bin | Bin 0 -> 12404 bytes .../deth/scene00/frame20_bitstream.bin | Bin 0 -> 4323 bytes .../deth/scene00/frame20_maskstream.bin | Bin 0 -> 12626 bytes .../deth/scene00/frame21_bitstream.bin | Bin 0 -> 4268 bytes .../deth/scene00/frame21_maskstream.bin | Bin 0 -> 12512 bytes .../deth/scene00/frame22_bitstream.bin | Bin 0 -> 4231 bytes .../deth/scene00/frame22_maskstream.bin | Bin 0 -> 12330 bytes .../deth/scene00/frame23_bitstream.bin | Bin 0 -> 4108 bytes .../deth/scene00/frame23_maskstream.bin | Bin 0 -> 12114 bytes .../deth/scene00/frame24_bitstream.bin | Bin 0 -> 4156 bytes .../deth/scene00/frame24_maskstream.bin | Bin 0 -> 12436 bytes .../deth/scene00/frame25_bitstream.bin | Bin 0 -> 4133 bytes .../deth/scene00/frame25_maskstream.bin | Bin 0 -> 12298 bytes .../deth/scene00/frame26_bitstream.bin | Bin 0 -> 4175 bytes .../deth/scene00/frame26_maskstream.bin | Bin 0 -> 12444 bytes .../deth/scene00/frame27_bitstream.bin | Bin 0 -> 4065 bytes .../deth/scene00/frame27_maskstream.bin | Bin 0 -> 12360 bytes .../deth/scene00/frame28_bitstream.bin | Bin 0 -> 4124 bytes .../deth/scene00/frame28_maskstream.bin | Bin 0 -> 12386 bytes .../deth/scene00/frame29_bitstream.bin | Bin 0 -> 4159 bytes .../deth/scene00/frame29_maskstream.bin | Bin 0 -> 12504 bytes .../deth/scene00/frame30_bitstream.bin | Bin 0 -> 4261 bytes .../deth/scene00/frame30_maskstream.bin | Bin 0 -> 13074 bytes .../_testdata/deth/scene00/paletteLookup.bin | Bin 0 -> 98336 bytes .../_testdata/deth/scene01/controlDict.bin | Bin 0 -> 41052 bytes .../deth/scene01/frame00_bitstream.bin | Bin 0 -> 9964 bytes .../deth/scene01/frame00_maskstream.bin | Bin 0 -> 20024 bytes .../deth/scene01/frame01_bitstream.bin | Bin 0 -> 5511 bytes .../deth/scene01/frame01_maskstream.bin | Bin 0 -> 13230 bytes .../deth/scene01/frame02_bitstream.bin | Bin 0 -> 11606 bytes .../deth/scene01/frame02_maskstream.bin | Bin 0 -> 31552 bytes .../deth/scene01/frame03_bitstream.bin | Bin 0 -> 3071 bytes .../deth/scene01/frame03_maskstream.bin | Bin 0 -> 7344 bytes .../deth/scene01/frame04_bitstream.bin | Bin 0 -> 2745 bytes .../deth/scene01/frame04_maskstream.bin | Bin 0 -> 6540 bytes .../deth/scene01/frame05_bitstream.bin | Bin 0 -> 2510 bytes .../deth/scene01/frame05_maskstream.bin | Bin 0 -> 5876 bytes .../deth/scene01/frame06_bitstream.bin | Bin 0 -> 2461 bytes .../deth/scene01/frame06_maskstream.bin | Bin 0 -> 5734 bytes .../deth/scene01/frame07_bitstream.bin | Bin 0 -> 2610 bytes .../deth/scene01/frame07_maskstream.bin | Bin 0 -> 6324 bytes .../deth/scene01/frame08_bitstream.bin | Bin 0 -> 2620 bytes .../deth/scene01/frame08_maskstream.bin | Bin 0 -> 6456 bytes .../deth/scene01/frame09_bitstream.bin | Bin 0 -> 2565 bytes .../deth/scene01/frame09_maskstream.bin | Bin 0 -> 6290 bytes .../deth/scene01/frame10_bitstream.bin | Bin 0 -> 2477 bytes .../deth/scene01/frame10_maskstream.bin | Bin 0 -> 6020 bytes .../deth/scene01/frame11_bitstream.bin | Bin 0 -> 2587 bytes .../deth/scene01/frame11_maskstream.bin | Bin 0 -> 6480 bytes .../deth/scene01/frame12_bitstream.bin | Bin 0 -> 2569 bytes .../deth/scene01/frame12_maskstream.bin | Bin 0 -> 6368 bytes .../deth/scene01/frame13_bitstream.bin | Bin 0 -> 2443 bytes .../deth/scene01/frame13_maskstream.bin | Bin 0 -> 5948 bytes .../deth/scene01/frame14_bitstream.bin | Bin 0 -> 2557 bytes .../deth/scene01/frame14_maskstream.bin | Bin 0 -> 6432 bytes .../deth/scene01/frame15_bitstream.bin | Bin 0 -> 2598 bytes .../deth/scene01/frame15_maskstream.bin | Bin 0 -> 6616 bytes .../deth/scene01/frame16_bitstream.bin | Bin 0 -> 2257 bytes .../deth/scene01/frame16_maskstream.bin | Bin 0 -> 5626 bytes .../deth/scene01/frame17_bitstream.bin | Bin 0 -> 2091 bytes .../deth/scene01/frame17_maskstream.bin | Bin 0 -> 5126 bytes .../deth/scene01/frame18_bitstream.bin | Bin 0 -> 2054 bytes .../deth/scene01/frame18_maskstream.bin | Bin 0 -> 5106 bytes .../deth/scene01/frame19_bitstream.bin | Bin 0 -> 2133 bytes .../deth/scene01/frame19_maskstream.bin | Bin 0 -> 5452 bytes .../deth/scene01/frame20_bitstream.bin | Bin 0 -> 2056 bytes .../deth/scene01/frame20_maskstream.bin | Bin 0 -> 5090 bytes .../deth/scene01/frame21_bitstream.bin | Bin 0 -> 2015 bytes .../deth/scene01/frame21_maskstream.bin | Bin 0 -> 4928 bytes .../deth/scene01/frame22_bitstream.bin | Bin 0 -> 1969 bytes .../deth/scene01/frame22_maskstream.bin | Bin 0 -> 4940 bytes .../deth/scene01/frame23_bitstream.bin | Bin 0 -> 2087 bytes .../deth/scene01/frame23_maskstream.bin | Bin 0 -> 5252 bytes .../deth/scene01/frame24_bitstream.bin | Bin 0 -> 2155 bytes .../deth/scene01/frame24_maskstream.bin | Bin 0 -> 5390 bytes .../deth/scene01/frame25_bitstream.bin | Bin 0 -> 2069 bytes .../deth/scene01/frame25_maskstream.bin | Bin 0 -> 5080 bytes .../deth/scene01/frame26_bitstream.bin | Bin 0 -> 1972 bytes .../deth/scene01/frame26_maskstream.bin | Bin 0 -> 4820 bytes .../deth/scene01/frame27_bitstream.bin | Bin 0 -> 1859 bytes .../deth/scene01/frame27_maskstream.bin | Bin 0 -> 4704 bytes .../deth/scene01/frame28_bitstream.bin | Bin 0 -> 1821 bytes .../deth/scene01/frame28_maskstream.bin | Bin 0 -> 4538 bytes .../_testdata/deth/scene01/paletteLookup.bin | Bin 0 -> 58324 bytes .../_testdata/deth/scene02/controlDict.bin | Bin 0 -> 44304 bytes .../deth/scene02/frame00_bitstream.bin | Bin 0 -> 8951 bytes .../deth/scene02/frame00_maskstream.bin | Bin 0 -> 24598 bytes .../deth/scene02/frame01_bitstream.bin | Bin 0 -> 3540 bytes .../deth/scene02/frame01_maskstream.bin | Bin 0 -> 9744 bytes .../deth/scene02/frame02_bitstream.bin | Bin 0 -> 3624 bytes .../deth/scene02/frame02_maskstream.bin | Bin 0 -> 10358 bytes .../deth/scene02/frame03_bitstream.bin | Bin 0 -> 3708 bytes .../deth/scene02/frame03_maskstream.bin | Bin 0 -> 10694 bytes .../deth/scene02/frame04_bitstream.bin | Bin 0 -> 3722 bytes .../deth/scene02/frame04_maskstream.bin | Bin 0 -> 11036 bytes .../deth/scene02/frame05_bitstream.bin | Bin 0 -> 3855 bytes .../deth/scene02/frame05_maskstream.bin | Bin 0 -> 11412 bytes .../deth/scene02/frame06_bitstream.bin | Bin 0 -> 3747 bytes .../deth/scene02/frame06_maskstream.bin | Bin 0 -> 10804 bytes .../deth/scene02/frame07_bitstream.bin | Bin 0 -> 3806 bytes .../deth/scene02/frame07_maskstream.bin | Bin 0 -> 10856 bytes .../deth/scene02/frame08_bitstream.bin | Bin 0 -> 4301 bytes .../deth/scene02/frame08_maskstream.bin | Bin 0 -> 11752 bytes .../deth/scene02/frame09_bitstream.bin | 4 + .../deth/scene02/frame09_maskstream.bin | Bin 0 -> 2142 bytes .../deth/scene02/frame10_bitstream.bin | Bin 0 -> 1090 bytes .../deth/scene02/frame10_maskstream.bin | Bin 0 -> 1548 bytes .../deth/scene02/frame11_bitstream.bin | Bin 0 -> 1390 bytes .../deth/scene02/frame11_maskstream.bin | Bin 0 -> 2028 bytes .../deth/scene02/frame12_bitstream.bin | Bin 0 -> 1257 bytes .../deth/scene02/frame12_maskstream.bin | Bin 0 -> 1806 bytes .../deth/scene02/frame13_bitstream.bin | Bin 0 -> 1403 bytes .../deth/scene02/frame13_maskstream.bin | Bin 0 -> 2186 bytes .../deth/scene02/frame14_bitstream.bin | Bin 0 -> 1195 bytes .../deth/scene02/frame14_maskstream.bin | Bin 0 -> 1748 bytes .../deth/scene02/frame15_bitstream.bin | Bin 0 -> 1277 bytes .../deth/scene02/frame15_maskstream.bin | Bin 0 -> 1820 bytes .../deth/scene02/frame16_bitstream.bin | Bin 0 -> 1312 bytes .../deth/scene02/frame16_maskstream.bin | Bin 0 -> 1814 bytes .../deth/scene02/frame17_bitstream.bin | Bin 0 -> 1430 bytes .../deth/scene02/frame17_maskstream.bin | Bin 0 -> 2234 bytes .../deth/scene02/frame18_bitstream.bin | Bin 0 -> 1092 bytes .../deth/scene02/frame18_maskstream.bin | Bin 0 -> 1534 bytes .../deth/scene02/frame19_bitstream.bin | Bin 0 -> 1238 bytes .../deth/scene02/frame19_maskstream.bin | Bin 0 -> 1766 bytes .../deth/scene02/frame20_bitstream.bin | Bin 0 -> 1347 bytes .../deth/scene02/frame20_maskstream.bin | Bin 0 -> 1904 bytes .../deth/scene02/frame21_bitstream.bin | 2 + .../deth/scene02/frame21_maskstream.bin | Bin 0 -> 532 bytes .../deth/scene02/frame22_bitstream.bin | 2 + .../deth/scene02/frame22_maskstream.bin | Bin 0 -> 486 bytes .../deth/scene02/frame23_bitstream.bin | Bin 0 -> 284 bytes .../deth/scene02/frame23_maskstream.bin | Bin 0 -> 394 bytes .../deth/scene02/frame24_bitstream.bin | 3 + .../deth/scene02/frame24_maskstream.bin | Bin 0 -> 420 bytes .../deth/scene02/frame25_bitstream.bin | Bin 0 -> 259 bytes .../deth/scene02/frame25_maskstream.bin | Bin 0 -> 396 bytes .../deth/scene02/frame26_bitstream.bin | 3 + .../deth/scene02/frame26_maskstream.bin | Bin 0 -> 484 bytes .../deth/scene02/frame27_bitstream.bin | 1 + .../deth/scene02/frame27_maskstream.bin | Bin 0 -> 370 bytes .../deth/scene02/frame28_bitstream.bin | Bin 0 -> 273 bytes .../deth/scene02/frame28_maskstream.bin | Bin 0 -> 406 bytes .../deth/scene02/frame29_bitstream.bin | Bin 0 -> 275 bytes .../deth/scene02/frame29_maskstream.bin | Bin 0 -> 420 bytes .../_testdata/deth/scene02/paletteLookup.bin | Bin 0 -> 76221 bytes .../content/movie/internal/format/DataType.go | 25 + .../content/movie/internal/format/Entry.go | 305 +++++ .../content/movie/internal/format/Header.go | 73 ++ .../movie/internal/format/Header_test.go | 22 + .../movie/internal/format/IndexTableEntry.go | 26 + .../internal/format/IndexTableEntry_test.go | 26 + .../movie/internal/format/SubtitleControl.go | 33 + .../movie/internal/format/SubtitleHeader.go | 12 + .../movie/internal/format/Timestamp.go | 86 ++ .../movie/internal/format/Timestamp_test.go | 66 ++ .../ss1/content/numbers/BinaryCodedDecimal.go | 26 + .../numbers/BinaryCodedDecimal_test.go | 37 + modding/hacked/ss1/content/object/Bitmap3D.go | 73 ++ .../ss1/content/object/Bitmap3D_test.go | 40 + .../hacked/ss1/content/object/CommonFlags.go | 160 +++ .../ss1/content/object/CommonFlags_test.go | 98 ++ .../ss1/content/object/CommonProperties.go | 39 + .../hacked/ss1/content/object/Constants.go | 8 + .../hacked/ss1/content/object/DamageType.go | 103 ++ .../ss1/content/object/DamageType_test.go | 71 ++ .../hacked/ss1/content/object/Descriptors.go | 44 + .../ss1/content/object/Descriptors_test.go | 31 + .../ss1/content/object/DestroyEffect.go | 51 + .../ss1/content/object/DestroyEffect_test.go | 23 + .../hacked/ss1/content/object/PhysicsModel.go | 34 + .../ss1/content/object/PhysicsModel_test.go | 32 + modding/hacked/ss1/content/object/Pivot.go | 17 + .../hacked/ss1/content/object/Properties.go | 160 +++ .../ss1/content/object/Properties_test.go | 58 + .../hacked/ss1/content/object/RenderType.go | 48 + .../ss1/content/object/RenderType_test.go | 42 + .../hacked/ss1/content/object/Standards.go | 177 +++ .../ss1/content/object/Standards_test.go | 29 + modding/hacked/ss1/content/object/Triple.go | 104 ++ .../hacked/ss1/content/object/Triple_test.go | 50 + modding/hacked/ss1/content/object/doc.go | 2 + .../hacked/ss1/content/object/objprop/Ammo.go | 20 + .../ss1/content/object/objprop/Animating.go | 26 + .../ss1/content/object/objprop/Critters.go | 72 ++ .../object/objprop/CyberspaceProperties.go | 13 + .../object/objprop/GenericProperties.go | 15 + .../ss1/content/object/objprop/Grenades.go | 31 + .../hacked/ss1/content/object/objprop/Guns.go | 64 ++ .../ss1/content/object/objprop/Physics.go | 28 + .../ss1/content/object/objprop/SmallStuff.go | 15 + .../object/objprop/SpecificProperties.go | 18 + .../ss1/content/object/objprop/objprop.go | 48 + .../ss1/content/sound/SoundEffectCache.go | 70 ++ modding/hacked/ss1/content/text/Blocked.go | 42 + .../hacked/ss1/content/text/Blocked_test.go | 36 + modding/hacked/ss1/content/text/Cache.go | 114 ++ modding/hacked/ss1/content/text/Cache_test.go | 198 ++++ modding/hacked/ss1/content/text/Codepage.go | 10 + .../ss1/content/text/DefaultCodepage.go | 34 + .../ss1/content/text/DefaultCodepage_test.go | 27 + .../ss1/content/text/ElectronicMessage.go | 201 ++++ .../content/text/ElectronicMessageCache.go | 58 + .../text/ElectronicMessageCache_test.go | 189 +++ .../content/text/ElectronicMessage_test.go | 262 +++++ .../hacked/ss1/content/text/TabledCodepage.go | 33 + .../hacked/ss1/content/texture/Constants.go | 5 + .../hacked/ss1/content/texture/Properties.go | 35 + .../ss1/content/texture/Properties_test.go | 15 + .../content/texture/TransparencyControl.go | 33 + .../texture/TransparencyControl_test.go | 31 + modding/hacked/ss1/doc.go | 5 + modding/hacked/ss1/edit/AugmentedText.go | 138 +++ modding/hacked/ss1/edit/EditableLevels.go | 113 ++ modding/hacked/ss1/edit/GameObjectsService.go | 64 ++ modding/hacked/ss1/edit/GameStateService.go | 272 +++++ modding/hacked/ss1/edit/KnownResources.go | 55 + modding/hacked/ss1/edit/LevelEditorService.go | 257 +++++ .../hacked/ss1/edit/LevelSelectionService.go | 231 ++++ modding/hacked/ss1/edit/MovieService.go | 155 +++ modding/hacked/ss1/edit/ProjectService.go | 394 +++++++ modding/hacked/ss1/edit/SoundEffectService.go | 61 + modding/hacked/ss1/edit/doc.go | 3 + .../ss1/edit/media/AudioSetterService.go | 39 + .../ss1/edit/media/AudioViewerService.go | 38 + .../ss1/edit/media/MovieSetterService.go | 37 + .../ss1/edit/media/MovieViewerService.go | 68 ++ .../edit/media/SoundEffectSetterService.go | 42 + .../edit/media/SoundEffectViewerService.go | 38 + .../ss1/edit/media/TextSetterService.go | 59 + .../ss1/edit/media/TextViewerService.go | 54 + modding/hacked/ss1/edit/media/doc.go | 2 + .../hacked/ss1/edit/undoable/AugmentedText.go | 91 ++ modding/hacked/ss1/edit/undoable/Command.go | 25 + .../hacked/ss1/edit/undoable/MovieService.go | 131 +++ .../ss1/edit/undoable/SoundEffectService.go | 76 ++ .../hacked/ss1/edit/undoable/cmd/Command.go | 15 + .../hacked/ss1/edit/undoable/cmd/Commander.go | 6 + modding/hacked/ss1/edit/undoable/cmd/List.go | 30 + modding/hacked/ss1/edit/undoable/cmd/Stack.go | 100 ++ .../ss1/edit/undoable/cmd/Stack_test.go | 348 ++++++ .../ss1/edit/undoable/cmd/Transaction.go | 157 +++ .../ss1/edit/undoable/cmd/Transaction_test.go | 135 +++ modding/hacked/ss1/edit/undoable/doc.go | 2 + modding/hacked/ss1/resource/BlockProvider.go | 15 + modding/hacked/ss1/resource/Blocks.go | 62 + modding/hacked/ss1/resource/Blocks_test.go | 59 + modding/hacked/ss1/resource/ChangeNotifier.go | 131 +++ .../ss1/resource/ChangeNotifier_test.go | 136 +++ modding/hacked/ss1/resource/ContentType.go | 48 + .../hacked/ss1/resource/ContentType_test.go | 33 + modding/hacked/ss1/resource/Errors.go | 51 + modding/hacked/ss1/resource/Identifier.go | 47 + .../hacked/ss1/resource/Identifier_test.go | 39 + modding/hacked/ss1/resource/Key.go | 20 + modding/hacked/ss1/resource/Key_test.go | 14 + modding/hacked/ss1/resource/Language.go | 46 + modding/hacked/ss1/resource/Language_test.go | 58 + modding/hacked/ss1/resource/List.go | 24 + modding/hacked/ss1/resource/ListMerger.go | 62 + modding/hacked/ss1/resource/List_test.go | 52 + .../hacked/ss1/resource/LocalizedResources.go | 11 + .../ss1/resource/LocalizedResourcesList.go | 18 + modding/hacked/ss1/resource/Resource.go | 38 + modding/hacked/ss1/resource/Resource_test.go | 54 + modding/hacked/ss1/resource/Selector.go | 34 + modding/hacked/ss1/resource/Selector_test.go | 225 ++++ modding/hacked/ss1/resource/Store.go | 85 ++ modding/hacked/ss1/resource/Store_test.go | 146 +++ modding/hacked/ss1/resource/View.go | 29 + modding/hacked/ss1/resource/ViewStrategy.go | 9 + modding/hacked/ss1/resource/Viewer.go | 10 + modding/hacked/ss1/resource/doc.go | 4 + .../hacked/ss1/resource/lgres/BlockReader.go | 22 + .../hacked/ss1/resource/lgres/BlockWriter.go | 24 + .../resource/lgres/FragmentedChunkWriter.go | 64 ++ modding/hacked/ss1/resource/lgres/Reader.go | 256 +++++ .../ss1/resource/lgres/ReaderResource.go | 20 + .../hacked/ss1/resource/lgres/Reader_test.go | 155 +++ .../resource/lgres/ResourceDirectoryEntry.go | 52 + .../resource/lgres/ResourceDirectoryHeader.go | 6 + .../ss1/resource/lgres/ResourceWriter.go | 8 + modding/hacked/ss1/resource/lgres/Write.go | 72 ++ .../hacked/ss1/resource/lgres/Write_test.go | 44 + modding/hacked/ss1/resource/lgres/Writer.go | 177 +++ .../hacked/ss1/resource/lgres/Writer_test.go | 196 ++++ modding/hacked/ss1/resource/lgres/doc.go | 4 + .../ss1/resource/lgres/fixtures_test.go | 54 + .../lgres/internal/compression/Compressor.go | 84 ++ .../internal/compression/Compressor_test.go | 68 ++ .../internal/compression/Decompressor.go | 129 +++ .../internal/compression/Decompressor_test.go | 125 ++ .../lgres/internal/compression/DictEntry.go | 50 + .../internal/compression/DictEntryBuffer.go | 21 + .../lgres/internal/compression/Word.go | 14 + .../lgres/internal/compression/WordReader.go | 41 + .../internal/compression/WordReader_test.go | 56 + .../lgres/internal/compression/WordWriter.go | 59 + .../internal/compression/WordWriter_test.go | 49 + .../internal/compression/benchmark_test.go | 119 ++ .../lgres/internal/compression/doc.go | 4 + .../lgres/internal/format/Constants.go | 17 + modding/hacked/ss1/serial/ByteStore.go | 89 ++ modding/hacked/ss1/serial/Codable.go | 7 + modding/hacked/ss1/serial/Coder.go | 12 + modding/hacked/ss1/serial/Decoder.go | 58 + modding/hacked/ss1/serial/Decoder_test.go | 138 +++ modding/hacked/ss1/serial/Encoder.go | 56 + modding/hacked/ss1/serial/Encoder_test.go | 157 +++ .../hacked/ss1/serial/MockedCodable_test.go | 11 + modding/hacked/ss1/serial/Positioner.go | 9 + modding/hacked/ss1/serial/PositioningCoder.go | 9 + .../hacked/ss1/serial/PositioningDecoder.go | 27 + .../ss1/serial/PositioningDecoder_test.go | 64 ++ .../hacked/ss1/serial/PositioningEncoder.go | 57 + .../ss1/serial/PositioningEncoder_test.go | 161 +++ .../hacked/ss1/serial/SeekingReadCloser.go | 10 + .../hacked/ss1/serial/SeekingWriteCloser.go | 10 + modding/hacked/ss1/serial/doc.go | 4 + modding/hacked/ss1/serial/errorBuffer_test.go | 56 + modding/hacked/ss1/serial/rle/Compress.go | 177 +++ .../hacked/ss1/serial/rle/Compress_test.go | 79 ++ modding/hacked/ss1/serial/rle/Decompress.go | 67 ++ .../hacked/ss1/serial/rle/Decompress_test.go | 146 +++ .../hacked/ss1/serial/rle/benchmark_test.go | 83 ++ modding/hacked/ss1/world/BlockPatch.go | 19 + modding/hacked/ss1/world/Constants.go | 31 + modding/hacked/ss1/world/Defaults.go | 8 + modding/hacked/ss1/world/Defaults_test.go | 23 + modding/hacked/ss1/world/FileLoader.go | 208 ++++ modding/hacked/ss1/world/FileLocation.go | 48 + modding/hacked/ss1/world/FileLocation_test.go | 125 ++ modding/hacked/ss1/world/Manifest.go | 170 +++ modding/hacked/ss1/world/ManifestEntry.go | 53 + .../hacked/ss1/world/ManifestEntry_test.go | 92 ++ modding/hacked/ss1/world/Manifest_test.go | 478 ++++++++ modding/hacked/ss1/world/Mod.go | 324 ++++++ modding/hacked/ss1/world/ModData.go | 154 +++ modding/hacked/ss1/world/ModTransaction.go | 74 ++ modding/hacked/ss1/world/Mod_test.go | 235 ++++ modding/hacked/ss1/world/Modder.go | 41 + .../hacked/ss1/world/ResourceViewStrategy.go | 18 + modding/hacked/ss1/world/citadel/GameState.go | 39 + modding/hacked/ss1/world/citadel/Variables.go | 192 ++++ modding/hacked/ss1/world/doc.go | 4 + modding/hacked/ss1/world/ids/Filename.go | 61 + modding/hacked/ss1/world/ids/Filenames.go | 127 ++ .../hacked/ss1/world/ids/Filenames_test.go | 73 ++ modding/hacked/ss1/world/ids/Identifier.go | 88 ++ modding/hacked/ss1/world/ids/Info.go | 161 +++ modding/hacked/ss1/world/ids/Sounds.go | 197 ++++ modding/hacked/ss1/world/ids/doc.go | 3 + modding/hacked/ui/gui/Context.go | 420 +++++++ modding/hacked/ui/gui/ImageKey.go | 30 + modding/hacked/ui/gui/ModalState.go | 13 + modding/hacked/ui/gui/ModalStateMachine.go | 6 + modding/hacked/ui/gui/ModalStateWrapper.go | 28 + modding/hacked/ui/gui/StepSliderInt.go | 42 + modding/hacked/ui/input/Key.go | 95 ++ modding/hacked/ui/input/Modifier.go | 34 + modding/hacked/ui/input/Modifier_test.go | 41 + modding/hacked/ui/input/Shortcut.go | 34 + modding/hacked/ui/input/Shortcut_test.go | 22 + modding/hacked/ui/input/StickyKeyBuffer.go | 101 ++ .../hacked/ui/input/StickyKeyBuffer_test.go | 261 +++++ modding/hacked/ui/input/constants.go | 8 + modding/hacked/ui/native/NativeKeys.go | 73 ++ modding/hacked/ui/native/OpenGl.go | 378 ++++++ modding/hacked/ui/native/OpenGlWindow.go | 288 +++++ modding/hacked/ui/native/Run.go | 34 + modding/hacked/ui/opengl/CompileNewShader.go | 18 + modding/hacked/ui/opengl/DebugBuilder.go | 63 + modding/hacked/ui/opengl/DebuggingOpenGl.go | 490 ++++++++ modding/hacked/ui/opengl/Errors.go | 54 + modding/hacked/ui/opengl/OpenGl.go | 90 ++ modding/hacked/ui/opengl/Program.go | 35 + modding/hacked/ui/opengl/Uniforms.go | 25 + modding/hacked/ui/opengl/VertexArrayObject.go | 62 + modding/hacked/ui/opengl/Window.go | 147 +++ .../hacked/ui/opengl/WindowEventDispatcher.go | 128 +++ modding/hacked/ui/opengl/constants.go | 156 +++ modding/ss-specs/CONTRIBUTING.md | 52 + modding/ss-specs/LICENSE.txt | 1 + modding/ss-specs/README.md | 32 + modding/ss-specs/archives/gameState.md | 336 ++++++ modding/ss-specs/archives/gameVariables.md | 208 ++++ modding/ss-specs/archives/index.md | 80 ++ modding/ss-specs/archives/levelObjects.md | 175 +++ modding/ss-specs/archives/levelSchedules.md | 119 ++ modding/ss-specs/archives/levelVariables.md | 36 + .../ss-specs/archives/loopConfiguration.md | 24 + modding/ss-specs/archives/mapInformation.md | 252 ++++ .../ss-specs/archives/surveillanceCameras.md | 28 + modding/ss-specs/archives/textureAnimation.md | 30 + modding/ss-specs/content/AccessLevels.md | 9 + .../ss-specs/content/ElectronicMessages.md | 57 + modding/ss-specs/content/LevelTextures.md | 29 + modding/ss-specs/conventions.md | 72 ++ modding/ss-specs/fileFormat/PropertyFiles.md | 196 ++++ modding/ss-specs/fileFormat/ResourceFiles.md | 126 ++ modding/ss-specs/fileFormat/index.md | 188 +++ modding/ss-specs/index.md | 29 + .../levelObjects/00_Gun/gunProperties.md | 79 ++ .../levelObjects/00_Gun/levelGunEntry.md | 9 + .../levelObjects/01_Ammo/ammoProperties.md | 12 + .../levelObjects/01_Ammo/levelAmmoEntry.md | 7 + .../02_Physics/physicsProperties.md | 42 + .../03_Grenade/grenadeProperties.md | 50 + .../03_Grenade/levelGrenadeEntry.md | 44 + .../levelObjects/04_Drug/levelDrugEntry.md | 9 + .../05_Hardware/levelHardwareEntry.md | 9 + .../06_Software/levelSoftwareEntry.md | 92 ++ .../07_BigStuff/levelBigStuffEntry.md | 220 ++++ .../08_SmallStuff/levelSmallStuffEntry.md | 186 +++ .../08_SmallStuff/smallStuffProperties.md | 11 + .../09_Fixture/levelFixtureEntry.md | 266 +++++ .../levelObjects/10_Door/levelDoorEntry.md | 29 + .../11_Animating/animatingProperties.md | 27 + .../levelObjects/12_Trap/levelTrapEntry.md | 136 +++ .../13_Container/levelContainerEntry.md | 25 + .../14_Critter/critterProperties.md | 113 ++ .../14_Critter/levelCritterEntry.md | 89 ++ modding/ss-specs/levelObjects/Actions.md | 786 +++++++++++++ modding/ss-specs/levelObjects/Conditions.md | 66 ++ .../levelObjects/GenericWeaponInfo.md | 33 + modding/ss-specs/levelObjects/index.md | 99 ++ modding/ss-specs/media/Bitmaps.md | 97 ++ modding/ss-specs/media/Fonts.md | 33 + modding/ss-specs/media/Geometry.md | 182 +++ modding/ss-specs/media/Palettes.md | 40 + modding/ss-specs/media/SoundEffects.md | 45 + modding/ss-specs/media/Texts.md | 67 ++ modding/ss-specs/media/VideoClips.md | 57 + modding/ss-specs/media/moviResources.md | 258 +++++ modding/ss-specs/other/Music.md | 7 + registry.env.example | 5 + 755 files changed, 50424 insertions(+) create mode 100644 modding/Makefile create mode 100644 modding/README.md create mode 100755 modding/hacked-build-image.sh create mode 100644 modding/hacked-build-image/Dockerfile create mode 100644 modding/hacked-build-image/VERSION create mode 100755 modding/hacked-build-image/build-hacked.sh create mode 100755 modding/hacked-build-image/docker-entrypoint.sh create mode 100755 modding/hacked-build-run-image.sh create mode 100755 modding/hacked-build-upload-image.sh create mode 100644 modding/hacked/.gitattributes create mode 100644 modding/hacked/.gitignore create mode 100644 modding/hacked/.golangci.yml create mode 100644 modding/hacked/CONTRIBUTING.md create mode 100644 modding/hacked/LICENSE create mode 100644 modding/hacked/README.md create mode 100644 modding/hacked/_resources/build/win/hacked.exe.manifest create mode 100755 modding/hacked/_resources/build/win/hacked.ico create mode 100644 modding/hacked/_resources/build/win/hacked.rc create mode 100644 modding/hacked/_resources/dist/README.md create mode 100755 modding/hacked/build.sh create mode 100644 modding/hacked/crash/Handler.go create mode 100644 modding/hacked/crash/Handler_windows.go create mode 100644 modding/hacked/editor/Application.go create mode 100644 modding/hacked/editor/ClipboardAdapter.go create mode 100644 modding/hacked/editor/about/LicensesView.go create mode 100644 modding/hacked/editor/about/View.go create mode 100644 modding/hacked/editor/about/ViewModel.go create mode 100644 modding/hacked/editor/about/licenses.go create mode 100644 modding/hacked/editor/animations/SetAnimationCommand.go create mode 100644 modding/hacked/editor/animations/View.go create mode 100644 modding/hacked/editor/animations/ViewModel.go create mode 100644 modding/hacked/editor/archives/EditBooleanVariableDialog.go create mode 100644 modding/hacked/editor/archives/EditIntegerVariableDialog.go create mode 100644 modding/hacked/editor/archives/SetArchiveDataCommand.go create mode 100644 modding/hacked/editor/archives/View.go create mode 100644 modding/hacked/editor/archives/ViewModel.go create mode 100644 modding/hacked/editor/bitmaps/SetBitmapCommand.go create mode 100644 modding/hacked/editor/bitmaps/View.go create mode 100644 modding/hacked/editor/bitmaps/ViewModel.go create mode 100644 modding/hacked/editor/doc.go create mode 100644 modding/hacked/editor/external/Clipboard.go create mode 100644 modding/hacked/editor/external/Export.go create mode 100644 modding/hacked/editor/external/ExportStartState.go create mode 100644 modding/hacked/editor/external/ExportWaitingState.go create mode 100644 modding/hacked/editor/external/FileDialog.go create mode 100644 modding/hacked/editor/external/Import.go create mode 100644 modding/hacked/editor/external/ImportStartState.go create mode 100644 modding/hacked/editor/external/ImportWaitingState.go create mode 100644 modding/hacked/editor/external/TypeInfo.go create mode 100644 modding/hacked/editor/graphics/BitmapTexture.go create mode 100644 modding/hacked/editor/graphics/FrameCache.go create mode 100644 modding/hacked/editor/graphics/PaletteCache.go create mode 100644 modding/hacked/editor/graphics/PaletteTexture.go create mode 100644 modding/hacked/editor/graphics/TextureCache.go create mode 100644 modding/hacked/editor/levels/BackgroundGrid.go create mode 100644 modding/hacked/editor/levels/ColorDisplay.go create mode 100644 modding/hacked/editor/levels/Constants.go create mode 100644 modding/hacked/editor/levels/ControlView.go create mode 100644 modding/hacked/editor/levels/ControlViewModel.go create mode 100644 modding/hacked/editor/levels/Formatters.go create mode 100644 modding/hacked/editor/levels/Highlighter.go create mode 100644 modding/hacked/editor/levels/HoverItems.go create mode 100644 modding/hacked/editor/levels/LimitedCamera.go create mode 100644 modding/hacked/editor/levels/MapColors.go create mode 100644 modding/hacked/editor/levels/MapDisplay.go create mode 100644 modding/hacked/editor/levels/MapGrid.go create mode 100644 modding/hacked/editor/levels/MapIcons.go create mode 100644 modding/hacked/editor/levels/MapPosition.go create mode 100644 modding/hacked/editor/levels/MapTextures.go create mode 100644 modding/hacked/editor/levels/ObjectsView.go create mode 100644 modding/hacked/editor/levels/ObjectsViewModel.go create mode 100644 modding/hacked/editor/levels/TextureDisplay.go create mode 100644 modding/hacked/editor/levels/TilesView.go create mode 100644 modding/hacked/editor/levels/TilesViewModel.go create mode 100644 modding/hacked/editor/levels/hazards.go create mode 100644 modding/hacked/editor/messages/SetMessageDataCommand.go create mode 100644 modding/hacked/editor/messages/View.go create mode 100644 modding/hacked/editor/messages/ViewModel.go create mode 100644 modding/hacked/editor/movies/CompressingStartState.go create mode 100644 modding/hacked/editor/movies/CompressingWaitingState.go create mode 100644 modding/hacked/editor/movies/CompressionTask.go create mode 100644 modding/hacked/editor/movies/View.go create mode 100644 modding/hacked/editor/movies/ViewModel.go create mode 100644 modding/hacked/editor/objects/SetObjectBitmapCommand.go create mode 100644 modding/hacked/editor/objects/SetObjectPropertiesCommand.go create mode 100644 modding/hacked/editor/objects/SetObjectTextCommand.go create mode 100644 modding/hacked/editor/objects/View.go create mode 100644 modding/hacked/editor/objects/ViewModel.go create mode 100644 modding/hacked/editor/project/AddManifestEntryStartState.go create mode 100644 modding/hacked/editor/project/AddManifestEntryWaitingState.go create mode 100644 modding/hacked/editor/project/LoadModStartState.go create mode 100644 modding/hacked/editor/project/LoadModWaitingState.go create mode 100644 modding/hacked/editor/project/SaveModAsStartState.go create mode 100644 modding/hacked/editor/project/SaveModAsWaitingState.go create mode 100644 modding/hacked/editor/project/SaveModFailedState.go create mode 100644 modding/hacked/editor/project/View.go create mode 100644 modding/hacked/editor/project/ViewModel.go create mode 100644 modding/hacked/editor/render/Context.go create mode 100644 modding/hacked/editor/render/FrameImage.go create mode 100644 modding/hacked/editor/render/OrientationView.go create mode 100644 modding/hacked/editor/render/TextureID.go create mode 100644 modding/hacked/editor/render/TextureImage.go create mode 100644 modding/hacked/editor/render/TextureRenderer.go create mode 100644 modding/hacked/editor/render/TextureSelector.go create mode 100644 modding/hacked/editor/sounds/View.go create mode 100644 modding/hacked/editor/sounds/ViewModel.go create mode 100644 modding/hacked/editor/texts/View.go create mode 100644 modding/hacked/editor/texts/ViewModel.go create mode 100644 modding/hacked/editor/textures/SetTextureBitmapCommand.go create mode 100644 modding/hacked/editor/textures/SetTexturePropertiesCommand.go create mode 100644 modding/hacked/editor/textures/SetTextureTextCommand.go create mode 100644 modding/hacked/editor/textures/View.go create mode 100644 modding/hacked/editor/textures/ViewModel.go create mode 100644 modding/hacked/editor/values/InterpreterControls.go create mode 100644 modding/hacked/editor/values/ObjectTypeControlRenderer.go create mode 100644 modding/hacked/editor/values/Properties.go create mode 100644 modding/hacked/editor/values/Unifier.go create mode 100644 modding/hacked/editor/values/Unifier_test.go create mode 100644 modding/hacked/go.mod create mode 100644 modding/hacked/go.sum create mode 100644 modding/hacked/main.go create mode 100644 modding/hacked/ss1/StringError.go create mode 100644 modding/hacked/ss1/content/archive/Constants.go create mode 100644 modding/hacked/ss1/content/archive/GameState.go create mode 100644 modding/hacked/ss1/content/archive/IsSavegame.go create mode 100644 modding/hacked/ss1/content/archive/IsSavegame_test.go create mode 100644 modding/hacked/ss1/content/archive/Variables.go create mode 100644 modding/hacked/ss1/content/archive/doc.go create mode 100644 modding/hacked/ss1/content/archive/level/BaseInfo.go create mode 100644 modding/hacked/ss1/content/archive/level/BaseInfo_test.go create mode 100644 modding/hacked/ss1/content/archive/level/BlockPuzzleState.go create mode 100644 modding/hacked/ss1/content/archive/level/BlockPuzzleState_test.go create mode 100644 modding/hacked/ss1/content/archive/level/Constants.go create mode 100644 modding/hacked/ss1/content/archive/level/Coordinate.go create mode 100644 modding/hacked/ss1/content/archive/level/CyberspaceFlightPull.go create mode 100644 modding/hacked/ss1/content/archive/level/Direction.go create mode 100644 modding/hacked/ss1/content/archive/level/Direction_test.go create mode 100644 modding/hacked/ss1/content/archive/level/EmptyLevelData.go create mode 100644 modding/hacked/ss1/content/archive/level/EmptyLevelData_test.go create mode 100644 modding/hacked/ss1/content/archive/level/Errors.go create mode 100644 modding/hacked/ss1/content/archive/level/FloorCeilingInfo.go create mode 100644 modding/hacked/ss1/content/archive/level/FloorCeilingInfo_test.go create mode 100644 modding/hacked/ss1/content/archive/level/HeightSemaphores.go create mode 100644 modding/hacked/ss1/content/archive/level/HeightShift.go create mode 100644 modding/hacked/ss1/content/archive/level/HeightUnit.go create mode 100644 modding/hacked/ss1/content/archive/level/Level.go create mode 100644 modding/hacked/ss1/content/archive/level/LightDeltaInfo.go create mode 100644 modding/hacked/ss1/content/archive/level/LoopConfigEntry.go create mode 100644 modding/hacked/ss1/content/archive/level/MapNotes.go create mode 100644 modding/hacked/ss1/content/archive/level/ObjectClassInfo.go create mode 100644 modding/hacked/ss1/content/archive/level/ObjectClassTable.go create mode 100644 modding/hacked/ss1/content/archive/level/ObjectClassTable_test.go create mode 100644 modding/hacked/ss1/content/archive/level/ObjectCrossReferenceTable.go create mode 100644 modding/hacked/ss1/content/archive/level/ObjectCrossReferenceTable_test.go create mode 100644 modding/hacked/ss1/content/archive/level/ObjectID.go create mode 100644 modding/hacked/ss1/content/archive/level/ObjectMainTable.go create mode 100644 modding/hacked/ss1/content/archive/level/ObjectMainTable_test.go create mode 100644 modding/hacked/ss1/content/archive/level/Parameters.go create mode 100644 modding/hacked/ss1/content/archive/level/Parameters_test.go create mode 100644 modding/hacked/ss1/content/archive/level/RotationUnit.go create mode 100644 modding/hacked/ss1/content/archive/level/SchedulerInfo.go create mode 100644 modding/hacked/ss1/content/archive/level/Surveillance.go create mode 100644 modding/hacked/ss1/content/archive/level/TextureAnimationEntry.go create mode 100644 modding/hacked/ss1/content/archive/level/TextureIndex.go create mode 100644 modding/hacked/ss1/content/archive/level/TileFlag.go create mode 100644 modding/hacked/ss1/content/archive/level/TileHeightUnit.go create mode 100644 modding/hacked/ss1/content/archive/level/TileMap.go create mode 100644 modding/hacked/ss1/content/archive/level/TileMap_test.go create mode 100644 modding/hacked/ss1/content/archive/level/TilePosition.go create mode 100644 modding/hacked/ss1/content/archive/level/TilePosition_test.go create mode 100644 modding/hacked/ss1/content/archive/level/TileSlopeControl.go create mode 100644 modding/hacked/ss1/content/archive/level/TileTextureInfo.go create mode 100644 modding/hacked/ss1/content/archive/level/TileType.go create mode 100644 modding/hacked/ss1/content/archive/level/TileTypeInfo.go create mode 100644 modding/hacked/ss1/content/archive/level/TileType_test.go create mode 100644 modding/hacked/ss1/content/archive/level/Unknowns.go create mode 100644 modding/hacked/ss1/content/archive/level/WallHeights.go create mode 100644 modding/hacked/ss1/content/archive/level/WallHeights_test.go create mode 100644 modding/hacked/ss1/content/archive/level/WallTexturePattern.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlids/Identifier.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/Animating.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/BigStuff.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/Containers.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/Critters.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/Doors.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/Fixtures.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/ForCyberspace.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/ForRealWorld.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/ForceColors.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/Grenades.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/Guns.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/Hardware.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/InterpreterFactory.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/InterpreterRetriever.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/Physics.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/SmallStuff.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/Software.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/Traps.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/actions/Actions.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/conditions/GameVariable.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/conditions/Objects.go create mode 100644 modding/hacked/ss1/content/archive/level/lvlobj/lvlobj.go create mode 100644 modding/hacked/ss1/content/audio/L8.go create mode 100644 modding/hacked/ss1/content/audio/voc/BlockType.go create mode 100644 modding/hacked/ss1/content/audio/voc/Load.go create mode 100644 modding/hacked/ss1/content/audio/voc/Load_test.go create mode 100644 modding/hacked/ss1/content/audio/voc/Save.go create mode 100644 modding/hacked/ss1/content/audio/voc/Utility.go create mode 100644 modding/hacked/ss1/content/audio/voc/Utility_test.go create mode 100644 modding/hacked/ss1/content/audio/wav/Load.go create mode 100644 modding/hacked/ss1/content/audio/wav/Load_test.go create mode 100644 modding/hacked/ss1/content/audio/wav/Save.go create mode 100644 modding/hacked/ss1/content/audio/wav/Save_test.go create mode 100644 modding/hacked/ss1/content/audio/wav/WaveLoader.go create mode 100644 modding/hacked/ss1/content/audio/wav/formatHeader.go create mode 100644 modding/hacked/ss1/content/audio/wav/riff.go create mode 100644 modding/hacked/ss1/content/bitmap/Animation.go create mode 100644 modding/hacked/ss1/content/bitmap/AnimationCache.go create mode 100644 modding/hacked/ss1/content/bitmap/AnimationCache_test.go create mode 100644 modding/hacked/ss1/content/bitmap/Animation_test.go create mode 100644 modding/hacked/ss1/content/bitmap/Bitmap.go create mode 100644 modding/hacked/ss1/content/bitmap/Bitmap_test.go create mode 100644 modding/hacked/ss1/content/bitmap/Bitmapper.go create mode 100644 modding/hacked/ss1/content/bitmap/Constants.go create mode 100644 modding/hacked/ss1/content/bitmap/Header.go create mode 100644 modding/hacked/ss1/content/bitmap/Palette.go create mode 100644 modding/hacked/ss1/content/bitmap/PaletteCache.go create mode 100644 modding/hacked/ss1/content/bitmap/PaletteCache_test.go create mode 100644 modding/hacked/ss1/content/doc.go create mode 100644 modding/hacked/ss1/content/interpreters/Description.go create mode 100644 modding/hacked/ss1/content/interpreters/Description_test.go create mode 100644 modding/hacked/ss1/content/interpreters/Entry.go create mode 100644 modding/hacked/ss1/content/interpreters/Instance.go create mode 100644 modding/hacked/ss1/content/interpreters/Instance_test.go create mode 100644 modding/hacked/ss1/content/interpreters/Refinement.go create mode 100644 modding/hacked/ss1/content/interpreters/Simplifier.go create mode 100644 modding/hacked/ss1/content/interpreters/Simplifier_test.go create mode 100644 modding/hacked/ss1/content/movie/Audio.go create mode 100644 modding/hacked/ss1/content/movie/Cache.go create mode 100644 modding/hacked/ss1/content/movie/ContainSoundData.go create mode 100644 modding/hacked/ss1/content/movie/Container.go create mode 100644 modding/hacked/ss1/content/movie/Read.go create mode 100644 modding/hacked/ss1/content/movie/Read_test.go create mode 100644 modding/hacked/ss1/content/movie/Scene.go create mode 100644 modding/hacked/ss1/content/movie/Subtitles.go create mode 100644 modding/hacked/ss1/content/movie/Video.go create mode 100644 modding/hacked/ss1/content/movie/Write.go create mode 100644 modding/hacked/ss1/content/movie/Write_test.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/BitstreamReader.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/BitstreamReader_test.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/BitstreamWriter.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/BitstreamWriter_test.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/Compression_test.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/Constants.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/ControlType.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/ControlWord.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/ControlWordSequencer.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/ControlWordSequencer_test.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/ControlWord_test.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/Errors.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/FrameDecoder.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/FrameDecoderBuilder.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/Maskstream.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/Maskstream_test.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/PackControlWords.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/PackControlWords_test.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/PackedControlWord.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/PackedControlWord_test.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/PaletteLookupGenerator.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/SceneEncoder.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/StandardTileColorer.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/StandardTileColorer_test.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/TileColorFunction.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/TilePaletteKey.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/TilePaletteKey_test.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/UnpackControlWords.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/UnpackControlWords_test.go create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/controlDict.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame00_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame00_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame01_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame01_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame02_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame02_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame03_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame03_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame04_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame04_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame05_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame05_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame06_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame06_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame07_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame07_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame08_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame08_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame09_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame09_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame10_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame10_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame11_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame11_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame12_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame12_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame13_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame13_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame14_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame14_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame15_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame15_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame16_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame16_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame17_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame17_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame18_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame18_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame19_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame19_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame20_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame20_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame21_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame21_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame22_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame22_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame23_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame23_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame24_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame24_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame25_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame25_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame26_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame26_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame27_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame27_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame28_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame28_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame29_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame29_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame30_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame30_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/paletteLookup.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/controlDict.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame00_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame00_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame01_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame01_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame02_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame02_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame03_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame03_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame04_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame04_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame05_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame05_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame06_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame06_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame07_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame07_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame08_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame08_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame09_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame09_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame10_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame10_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame11_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame11_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame12_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame12_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame13_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame13_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame14_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame14_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame15_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame15_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame16_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame16_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame17_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame17_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame18_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame18_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame19_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame19_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame20_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame20_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame21_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame21_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame22_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame22_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame23_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame23_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame24_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame24_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame25_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame25_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame26_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame26_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame27_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame27_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame28_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame28_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/paletteLookup.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/controlDict.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame00_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame00_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame01_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame01_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame02_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame02_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame03_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame03_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame04_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame04_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame05_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame05_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame06_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame06_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame07_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame07_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame08_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame08_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame09_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame09_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame10_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame10_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame11_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame11_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame12_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame12_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame13_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame13_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame14_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame14_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame15_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame15_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame16_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame16_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame17_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame17_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame18_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame18_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame19_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame19_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame20_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame20_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame21_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame21_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame22_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame22_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame23_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame23_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame24_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame24_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame25_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame25_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame26_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame26_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame27_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame27_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame28_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame28_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame29_bitstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame29_maskstream.bin create mode 100644 modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/paletteLookup.bin create mode 100644 modding/hacked/ss1/content/movie/internal/format/DataType.go create mode 100644 modding/hacked/ss1/content/movie/internal/format/Entry.go create mode 100644 modding/hacked/ss1/content/movie/internal/format/Header.go create mode 100644 modding/hacked/ss1/content/movie/internal/format/Header_test.go create mode 100644 modding/hacked/ss1/content/movie/internal/format/IndexTableEntry.go create mode 100644 modding/hacked/ss1/content/movie/internal/format/IndexTableEntry_test.go create mode 100644 modding/hacked/ss1/content/movie/internal/format/SubtitleControl.go create mode 100644 modding/hacked/ss1/content/movie/internal/format/SubtitleHeader.go create mode 100644 modding/hacked/ss1/content/movie/internal/format/Timestamp.go create mode 100644 modding/hacked/ss1/content/movie/internal/format/Timestamp_test.go create mode 100644 modding/hacked/ss1/content/numbers/BinaryCodedDecimal.go create mode 100644 modding/hacked/ss1/content/numbers/BinaryCodedDecimal_test.go create mode 100644 modding/hacked/ss1/content/object/Bitmap3D.go create mode 100644 modding/hacked/ss1/content/object/Bitmap3D_test.go create mode 100644 modding/hacked/ss1/content/object/CommonFlags.go create mode 100644 modding/hacked/ss1/content/object/CommonFlags_test.go create mode 100644 modding/hacked/ss1/content/object/CommonProperties.go create mode 100644 modding/hacked/ss1/content/object/Constants.go create mode 100644 modding/hacked/ss1/content/object/DamageType.go create mode 100644 modding/hacked/ss1/content/object/DamageType_test.go create mode 100644 modding/hacked/ss1/content/object/Descriptors.go create mode 100644 modding/hacked/ss1/content/object/Descriptors_test.go create mode 100644 modding/hacked/ss1/content/object/DestroyEffect.go create mode 100644 modding/hacked/ss1/content/object/DestroyEffect_test.go create mode 100644 modding/hacked/ss1/content/object/PhysicsModel.go create mode 100644 modding/hacked/ss1/content/object/PhysicsModel_test.go create mode 100644 modding/hacked/ss1/content/object/Pivot.go create mode 100644 modding/hacked/ss1/content/object/Properties.go create mode 100644 modding/hacked/ss1/content/object/Properties_test.go create mode 100644 modding/hacked/ss1/content/object/RenderType.go create mode 100644 modding/hacked/ss1/content/object/RenderType_test.go create mode 100644 modding/hacked/ss1/content/object/Standards.go create mode 100644 modding/hacked/ss1/content/object/Standards_test.go create mode 100644 modding/hacked/ss1/content/object/Triple.go create mode 100644 modding/hacked/ss1/content/object/Triple_test.go create mode 100644 modding/hacked/ss1/content/object/doc.go create mode 100644 modding/hacked/ss1/content/object/objprop/Ammo.go create mode 100644 modding/hacked/ss1/content/object/objprop/Animating.go create mode 100644 modding/hacked/ss1/content/object/objprop/Critters.go create mode 100644 modding/hacked/ss1/content/object/objprop/CyberspaceProperties.go create mode 100644 modding/hacked/ss1/content/object/objprop/GenericProperties.go create mode 100644 modding/hacked/ss1/content/object/objprop/Grenades.go create mode 100644 modding/hacked/ss1/content/object/objprop/Guns.go create mode 100644 modding/hacked/ss1/content/object/objprop/Physics.go create mode 100644 modding/hacked/ss1/content/object/objprop/SmallStuff.go create mode 100644 modding/hacked/ss1/content/object/objprop/SpecificProperties.go create mode 100644 modding/hacked/ss1/content/object/objprop/objprop.go create mode 100644 modding/hacked/ss1/content/sound/SoundEffectCache.go create mode 100644 modding/hacked/ss1/content/text/Blocked.go create mode 100644 modding/hacked/ss1/content/text/Blocked_test.go create mode 100644 modding/hacked/ss1/content/text/Cache.go create mode 100644 modding/hacked/ss1/content/text/Cache_test.go create mode 100644 modding/hacked/ss1/content/text/Codepage.go create mode 100644 modding/hacked/ss1/content/text/DefaultCodepage.go create mode 100644 modding/hacked/ss1/content/text/DefaultCodepage_test.go create mode 100644 modding/hacked/ss1/content/text/ElectronicMessage.go create mode 100644 modding/hacked/ss1/content/text/ElectronicMessageCache.go create mode 100644 modding/hacked/ss1/content/text/ElectronicMessageCache_test.go create mode 100644 modding/hacked/ss1/content/text/ElectronicMessage_test.go create mode 100644 modding/hacked/ss1/content/text/TabledCodepage.go create mode 100644 modding/hacked/ss1/content/texture/Constants.go create mode 100644 modding/hacked/ss1/content/texture/Properties.go create mode 100644 modding/hacked/ss1/content/texture/Properties_test.go create mode 100644 modding/hacked/ss1/content/texture/TransparencyControl.go create mode 100644 modding/hacked/ss1/content/texture/TransparencyControl_test.go create mode 100644 modding/hacked/ss1/doc.go create mode 100644 modding/hacked/ss1/edit/AugmentedText.go create mode 100644 modding/hacked/ss1/edit/EditableLevels.go create mode 100644 modding/hacked/ss1/edit/GameObjectsService.go create mode 100644 modding/hacked/ss1/edit/GameStateService.go create mode 100644 modding/hacked/ss1/edit/KnownResources.go create mode 100644 modding/hacked/ss1/edit/LevelEditorService.go create mode 100644 modding/hacked/ss1/edit/LevelSelectionService.go create mode 100644 modding/hacked/ss1/edit/MovieService.go create mode 100644 modding/hacked/ss1/edit/ProjectService.go create mode 100644 modding/hacked/ss1/edit/SoundEffectService.go create mode 100644 modding/hacked/ss1/edit/doc.go create mode 100644 modding/hacked/ss1/edit/media/AudioSetterService.go create mode 100644 modding/hacked/ss1/edit/media/AudioViewerService.go create mode 100644 modding/hacked/ss1/edit/media/MovieSetterService.go create mode 100644 modding/hacked/ss1/edit/media/MovieViewerService.go create mode 100644 modding/hacked/ss1/edit/media/SoundEffectSetterService.go create mode 100644 modding/hacked/ss1/edit/media/SoundEffectViewerService.go create mode 100644 modding/hacked/ss1/edit/media/TextSetterService.go create mode 100644 modding/hacked/ss1/edit/media/TextViewerService.go create mode 100644 modding/hacked/ss1/edit/media/doc.go create mode 100644 modding/hacked/ss1/edit/undoable/AugmentedText.go create mode 100644 modding/hacked/ss1/edit/undoable/Command.go create mode 100644 modding/hacked/ss1/edit/undoable/MovieService.go create mode 100644 modding/hacked/ss1/edit/undoable/SoundEffectService.go create mode 100644 modding/hacked/ss1/edit/undoable/cmd/Command.go create mode 100644 modding/hacked/ss1/edit/undoable/cmd/Commander.go create mode 100644 modding/hacked/ss1/edit/undoable/cmd/List.go create mode 100644 modding/hacked/ss1/edit/undoable/cmd/Stack.go create mode 100644 modding/hacked/ss1/edit/undoable/cmd/Stack_test.go create mode 100644 modding/hacked/ss1/edit/undoable/cmd/Transaction.go create mode 100644 modding/hacked/ss1/edit/undoable/cmd/Transaction_test.go create mode 100644 modding/hacked/ss1/edit/undoable/doc.go create mode 100644 modding/hacked/ss1/resource/BlockProvider.go create mode 100644 modding/hacked/ss1/resource/Blocks.go create mode 100644 modding/hacked/ss1/resource/Blocks_test.go create mode 100644 modding/hacked/ss1/resource/ChangeNotifier.go create mode 100644 modding/hacked/ss1/resource/ChangeNotifier_test.go create mode 100644 modding/hacked/ss1/resource/ContentType.go create mode 100644 modding/hacked/ss1/resource/ContentType_test.go create mode 100644 modding/hacked/ss1/resource/Errors.go create mode 100644 modding/hacked/ss1/resource/Identifier.go create mode 100644 modding/hacked/ss1/resource/Identifier_test.go create mode 100644 modding/hacked/ss1/resource/Key.go create mode 100644 modding/hacked/ss1/resource/Key_test.go create mode 100644 modding/hacked/ss1/resource/Language.go create mode 100644 modding/hacked/ss1/resource/Language_test.go create mode 100644 modding/hacked/ss1/resource/List.go create mode 100644 modding/hacked/ss1/resource/ListMerger.go create mode 100644 modding/hacked/ss1/resource/List_test.go create mode 100644 modding/hacked/ss1/resource/LocalizedResources.go create mode 100644 modding/hacked/ss1/resource/LocalizedResourcesList.go create mode 100644 modding/hacked/ss1/resource/Resource.go create mode 100644 modding/hacked/ss1/resource/Resource_test.go create mode 100644 modding/hacked/ss1/resource/Selector.go create mode 100644 modding/hacked/ss1/resource/Selector_test.go create mode 100644 modding/hacked/ss1/resource/Store.go create mode 100644 modding/hacked/ss1/resource/Store_test.go create mode 100644 modding/hacked/ss1/resource/View.go create mode 100644 modding/hacked/ss1/resource/ViewStrategy.go create mode 100644 modding/hacked/ss1/resource/Viewer.go create mode 100644 modding/hacked/ss1/resource/doc.go create mode 100644 modding/hacked/ss1/resource/lgres/BlockReader.go create mode 100644 modding/hacked/ss1/resource/lgres/BlockWriter.go create mode 100644 modding/hacked/ss1/resource/lgres/FragmentedChunkWriter.go create mode 100644 modding/hacked/ss1/resource/lgres/Reader.go create mode 100644 modding/hacked/ss1/resource/lgres/ReaderResource.go create mode 100644 modding/hacked/ss1/resource/lgres/Reader_test.go create mode 100644 modding/hacked/ss1/resource/lgres/ResourceDirectoryEntry.go create mode 100644 modding/hacked/ss1/resource/lgres/ResourceDirectoryHeader.go create mode 100644 modding/hacked/ss1/resource/lgres/ResourceWriter.go create mode 100644 modding/hacked/ss1/resource/lgres/Write.go create mode 100644 modding/hacked/ss1/resource/lgres/Write_test.go create mode 100644 modding/hacked/ss1/resource/lgres/Writer.go create mode 100644 modding/hacked/ss1/resource/lgres/Writer_test.go create mode 100644 modding/hacked/ss1/resource/lgres/doc.go create mode 100644 modding/hacked/ss1/resource/lgres/fixtures_test.go create mode 100644 modding/hacked/ss1/resource/lgres/internal/compression/Compressor.go create mode 100644 modding/hacked/ss1/resource/lgres/internal/compression/Compressor_test.go create mode 100644 modding/hacked/ss1/resource/lgres/internal/compression/Decompressor.go create mode 100644 modding/hacked/ss1/resource/lgres/internal/compression/Decompressor_test.go create mode 100644 modding/hacked/ss1/resource/lgres/internal/compression/DictEntry.go create mode 100644 modding/hacked/ss1/resource/lgres/internal/compression/DictEntryBuffer.go create mode 100644 modding/hacked/ss1/resource/lgres/internal/compression/Word.go create mode 100644 modding/hacked/ss1/resource/lgres/internal/compression/WordReader.go create mode 100644 modding/hacked/ss1/resource/lgres/internal/compression/WordReader_test.go create mode 100644 modding/hacked/ss1/resource/lgres/internal/compression/WordWriter.go create mode 100644 modding/hacked/ss1/resource/lgres/internal/compression/WordWriter_test.go create mode 100644 modding/hacked/ss1/resource/lgres/internal/compression/benchmark_test.go create mode 100644 modding/hacked/ss1/resource/lgres/internal/compression/doc.go create mode 100644 modding/hacked/ss1/resource/lgres/internal/format/Constants.go create mode 100644 modding/hacked/ss1/serial/ByteStore.go create mode 100644 modding/hacked/ss1/serial/Codable.go create mode 100644 modding/hacked/ss1/serial/Coder.go create mode 100644 modding/hacked/ss1/serial/Decoder.go create mode 100644 modding/hacked/ss1/serial/Decoder_test.go create mode 100644 modding/hacked/ss1/serial/Encoder.go create mode 100644 modding/hacked/ss1/serial/Encoder_test.go create mode 100644 modding/hacked/ss1/serial/MockedCodable_test.go create mode 100644 modding/hacked/ss1/serial/Positioner.go create mode 100644 modding/hacked/ss1/serial/PositioningCoder.go create mode 100644 modding/hacked/ss1/serial/PositioningDecoder.go create mode 100644 modding/hacked/ss1/serial/PositioningDecoder_test.go create mode 100644 modding/hacked/ss1/serial/PositioningEncoder.go create mode 100644 modding/hacked/ss1/serial/PositioningEncoder_test.go create mode 100644 modding/hacked/ss1/serial/SeekingReadCloser.go create mode 100644 modding/hacked/ss1/serial/SeekingWriteCloser.go create mode 100644 modding/hacked/ss1/serial/doc.go create mode 100644 modding/hacked/ss1/serial/errorBuffer_test.go create mode 100644 modding/hacked/ss1/serial/rle/Compress.go create mode 100644 modding/hacked/ss1/serial/rle/Compress_test.go create mode 100644 modding/hacked/ss1/serial/rle/Decompress.go create mode 100644 modding/hacked/ss1/serial/rle/Decompress_test.go create mode 100644 modding/hacked/ss1/serial/rle/benchmark_test.go create mode 100644 modding/hacked/ss1/world/BlockPatch.go create mode 100644 modding/hacked/ss1/world/Constants.go create mode 100644 modding/hacked/ss1/world/Defaults.go create mode 100644 modding/hacked/ss1/world/Defaults_test.go create mode 100644 modding/hacked/ss1/world/FileLoader.go create mode 100644 modding/hacked/ss1/world/FileLocation.go create mode 100644 modding/hacked/ss1/world/FileLocation_test.go create mode 100644 modding/hacked/ss1/world/Manifest.go create mode 100644 modding/hacked/ss1/world/ManifestEntry.go create mode 100644 modding/hacked/ss1/world/ManifestEntry_test.go create mode 100644 modding/hacked/ss1/world/Manifest_test.go create mode 100644 modding/hacked/ss1/world/Mod.go create mode 100644 modding/hacked/ss1/world/ModData.go create mode 100644 modding/hacked/ss1/world/ModTransaction.go create mode 100644 modding/hacked/ss1/world/Mod_test.go create mode 100644 modding/hacked/ss1/world/Modder.go create mode 100644 modding/hacked/ss1/world/ResourceViewStrategy.go create mode 100644 modding/hacked/ss1/world/citadel/GameState.go create mode 100644 modding/hacked/ss1/world/citadel/Variables.go create mode 100644 modding/hacked/ss1/world/doc.go create mode 100644 modding/hacked/ss1/world/ids/Filename.go create mode 100644 modding/hacked/ss1/world/ids/Filenames.go create mode 100644 modding/hacked/ss1/world/ids/Filenames_test.go create mode 100644 modding/hacked/ss1/world/ids/Identifier.go create mode 100644 modding/hacked/ss1/world/ids/Info.go create mode 100644 modding/hacked/ss1/world/ids/Sounds.go create mode 100644 modding/hacked/ss1/world/ids/doc.go create mode 100644 modding/hacked/ui/gui/Context.go create mode 100644 modding/hacked/ui/gui/ImageKey.go create mode 100644 modding/hacked/ui/gui/ModalState.go create mode 100644 modding/hacked/ui/gui/ModalStateMachine.go create mode 100644 modding/hacked/ui/gui/ModalStateWrapper.go create mode 100644 modding/hacked/ui/gui/StepSliderInt.go create mode 100644 modding/hacked/ui/input/Key.go create mode 100644 modding/hacked/ui/input/Modifier.go create mode 100644 modding/hacked/ui/input/Modifier_test.go create mode 100644 modding/hacked/ui/input/Shortcut.go create mode 100644 modding/hacked/ui/input/Shortcut_test.go create mode 100644 modding/hacked/ui/input/StickyKeyBuffer.go create mode 100644 modding/hacked/ui/input/StickyKeyBuffer_test.go create mode 100644 modding/hacked/ui/input/constants.go create mode 100644 modding/hacked/ui/native/NativeKeys.go create mode 100644 modding/hacked/ui/native/OpenGl.go create mode 100644 modding/hacked/ui/native/OpenGlWindow.go create mode 100644 modding/hacked/ui/native/Run.go create mode 100644 modding/hacked/ui/opengl/CompileNewShader.go create mode 100644 modding/hacked/ui/opengl/DebugBuilder.go create mode 100644 modding/hacked/ui/opengl/DebuggingOpenGl.go create mode 100644 modding/hacked/ui/opengl/Errors.go create mode 100644 modding/hacked/ui/opengl/OpenGl.go create mode 100644 modding/hacked/ui/opengl/Program.go create mode 100644 modding/hacked/ui/opengl/Uniforms.go create mode 100644 modding/hacked/ui/opengl/VertexArrayObject.go create mode 100644 modding/hacked/ui/opengl/Window.go create mode 100644 modding/hacked/ui/opengl/WindowEventDispatcher.go create mode 100644 modding/hacked/ui/opengl/constants.go create mode 100644 modding/ss-specs/CONTRIBUTING.md create mode 100644 modding/ss-specs/LICENSE.txt create mode 100644 modding/ss-specs/README.md create mode 100644 modding/ss-specs/archives/gameState.md create mode 100644 modding/ss-specs/archives/gameVariables.md create mode 100644 modding/ss-specs/archives/index.md create mode 100644 modding/ss-specs/archives/levelObjects.md create mode 100644 modding/ss-specs/archives/levelSchedules.md create mode 100644 modding/ss-specs/archives/levelVariables.md create mode 100644 modding/ss-specs/archives/loopConfiguration.md create mode 100644 modding/ss-specs/archives/mapInformation.md create mode 100644 modding/ss-specs/archives/surveillanceCameras.md create mode 100644 modding/ss-specs/archives/textureAnimation.md create mode 100644 modding/ss-specs/content/AccessLevels.md create mode 100644 modding/ss-specs/content/ElectronicMessages.md create mode 100644 modding/ss-specs/content/LevelTextures.md create mode 100644 modding/ss-specs/conventions.md create mode 100644 modding/ss-specs/fileFormat/PropertyFiles.md create mode 100644 modding/ss-specs/fileFormat/ResourceFiles.md create mode 100644 modding/ss-specs/fileFormat/index.md create mode 100644 modding/ss-specs/index.md create mode 100644 modding/ss-specs/levelObjects/00_Gun/gunProperties.md create mode 100644 modding/ss-specs/levelObjects/00_Gun/levelGunEntry.md create mode 100644 modding/ss-specs/levelObjects/01_Ammo/ammoProperties.md create mode 100644 modding/ss-specs/levelObjects/01_Ammo/levelAmmoEntry.md create mode 100644 modding/ss-specs/levelObjects/02_Physics/physicsProperties.md create mode 100644 modding/ss-specs/levelObjects/03_Grenade/grenadeProperties.md create mode 100644 modding/ss-specs/levelObjects/03_Grenade/levelGrenadeEntry.md create mode 100644 modding/ss-specs/levelObjects/04_Drug/levelDrugEntry.md create mode 100644 modding/ss-specs/levelObjects/05_Hardware/levelHardwareEntry.md create mode 100644 modding/ss-specs/levelObjects/06_Software/levelSoftwareEntry.md create mode 100644 modding/ss-specs/levelObjects/07_BigStuff/levelBigStuffEntry.md create mode 100644 modding/ss-specs/levelObjects/08_SmallStuff/levelSmallStuffEntry.md create mode 100644 modding/ss-specs/levelObjects/08_SmallStuff/smallStuffProperties.md create mode 100644 modding/ss-specs/levelObjects/09_Fixture/levelFixtureEntry.md create mode 100644 modding/ss-specs/levelObjects/10_Door/levelDoorEntry.md create mode 100644 modding/ss-specs/levelObjects/11_Animating/animatingProperties.md create mode 100644 modding/ss-specs/levelObjects/12_Trap/levelTrapEntry.md create mode 100644 modding/ss-specs/levelObjects/13_Container/levelContainerEntry.md create mode 100644 modding/ss-specs/levelObjects/14_Critter/critterProperties.md create mode 100644 modding/ss-specs/levelObjects/14_Critter/levelCritterEntry.md create mode 100644 modding/ss-specs/levelObjects/Actions.md create mode 100644 modding/ss-specs/levelObjects/Conditions.md create mode 100644 modding/ss-specs/levelObjects/GenericWeaponInfo.md create mode 100644 modding/ss-specs/levelObjects/index.md create mode 100644 modding/ss-specs/media/Bitmaps.md create mode 100644 modding/ss-specs/media/Fonts.md create mode 100644 modding/ss-specs/media/Geometry.md create mode 100644 modding/ss-specs/media/Palettes.md create mode 100644 modding/ss-specs/media/SoundEffects.md create mode 100644 modding/ss-specs/media/Texts.md create mode 100644 modding/ss-specs/media/VideoClips.md create mode 100644 modding/ss-specs/media/moviResources.md create mode 100644 modding/ss-specs/other/Music.md diff --git a/.gitignore b/.gitignore index 7ca3e49..6c7825b 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ # Signing keys *.jks *.keystore + +# modding/hacked build output (see modding/README.md) +/modding/dist/ diff --git a/AGENTS.md b/AGENTS.md index dd0ab04..5f7a348 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,7 @@ These come from the maintainer. Follow them without being asked again. | `build-image/` | Docker build environment (`Dockerfile`, `VERSION`, `prepare-android-project.sh`, `build-apk.sh`, engine build scripts). | | `res/` | Bundled resources: `questshock.config.default.json`, `controller_profiles/` (JSON + PNG per controller), `splash.png` (startup splash), `logo.png` (1024x1024 logo, master of the launcher icons in `android/app/src/main/res/mipmap-*/`), `logo_small.png` (the README header), `screenshots/` (headset captures and videos named `questshock_v_.`, numbered per version), `store/` (store-listing artwork, generated from `logo.png`/`splash.png`/screenshots, not used by the app itself), `assets/` (where the user's own game data goes, git-ignored), `run.sh`/`run.bat`. | | `.gitea/workflows/build.yml`, `.github/workflows/release.yml` | CI. Gitea is the primary repository and builds on every push; GitHub is a mirror whose workflow builds and publishes a release when a `vX.Y.Z` tag is pushed. Each platform has its own file. | +| `modding/` | Separate subproject: System Shock modding tools (InkyBlackness `hacked`, `ss-specs`), unrelated to the VR app. Own `README.md`, `Makefile` and Docker build-image; see that README rather than this file. | | `Makefile` | `engine`, `dist`, `package`, `apk`, `android-studio`, `clean`, ... (see README section 2). | | `README.md`, `CONFIG.md`, `NOTICE.txt` | User docs, config reference, license notice. | diff --git a/modding/Makefile b/modding/Makefile new file mode 100644 index 0000000..acb4507 --- /dev/null +++ b/modding/Makefile @@ -0,0 +1,30 @@ +# Builds the modding subproject - see README.md. +# +# Targets: +# all (default) - alias for `hacked`. +# build-image - builds the hacked-build-image Docker image +# (./hacked-build-image.sh). Only needed once, or after +# hacked-build-image/ or hacked/go.mod|go.sum changes. +# hacked - compiles hacked/ for Linux and Windows via that image, +# offline. Output lands in dist/. +# clean - removes dist/. +.PHONY: all build-image hacked clean + +all: hacked + +build-image: + ./hacked-build-image.sh + +# If we're already running inside the hacked-build-image +# (HACKED_BUILD_IMAGE, set by its Dockerfile), compile directly instead of +# trying to docker-run it again - e.g. a CI job already using this image as +# its container. Otherwise, run it via Docker as usual. +hacked: + @if [ -n "$$HACKED_BUILD_IMAGE" ]; then \ + bash hacked-build-image/build-hacked.sh; \ + else \ + ./hacked-build-run-image.sh; \ + fi + +clean: + rm -rf dist diff --git a/modding/README.md b/modding/README.md new file mode 100644 index 0000000..140c442 --- /dev/null +++ b/modding/README.md @@ -0,0 +1,84 @@ +# Modding + +Tools and reference material for modding System Shock (1994) itself - distinct +from the rest of this repository, which is the [QuestShock](../README.md) VR +port of the game engine. Everything modding-related lives under this folder. + +Both pieces here come from the [InkyBlackness](https://inkyblackness.github.io) +project and are vendored unmodified, each under its own upstream license (see +below) - neither is relicensed by this repository's own MIT license. + +## Layout + +- `hacked/` - [InkyBlackness HackEd](https://github.com/inkyblackness/hacked), + the graphical mod editor. A vendored source snapshot (New BSD License), + taken from the `main` branch on 2026-09-26. To update it, replace this + directory with a fresh checkout and update that date. +- `ss-specs/` - [InkyBlackness ss-specs](https://github.com/inkyblackness/ss-specs), + the unofficial file-format specifications the editor (and any other modding + tool) is written against. Reference documentation only, not built. A + vendored snapshot (CC BY-NC-SA 4.0 - **non-commercial**, unlike the rest of + this repository), taken from the `main` branch on 2026-09-26. +- `hacked-build-image/` - the Dockerfile (and supporting scripts) for the + `hacked` build environment: the Go toolchain plus every dependency + `hacked/go.mod` pins, fetched once at image-build time so the actual build + needs no network access. Mirrors the main project's own `build-image/` + (see the repository root's [README.md](../README.md) section 4) one level + down, for this subproject. +- `hacked-build-image.sh` / `hacked-build-run-image.sh` / + `hacked-build-upload-image.sh` - build the image, run it to compile + `hacked/`, and push it to a registry, respectively. Same pattern as the + root project's own `build-image.sh`/`run-image.sh`/`upload-image.sh`. +- `Makefile` - `hacked` (default) builds `hacked/` via the image; `build-image` + (re)builds the image itself; `clean` removes `dist/`. +- `dist/` - build output (git-ignored), created by `make hacked`. + +Shockolate (the engine `../engine/` vendors) already has its own modding +support (level/resource files, `res/data/`); this folder is about the +external tools that create and inspect that content, not about changing the +engine itself. + +## Building `hacked` + +Packages `dist/.linux64.tgz` and `dist/.win64.zip` (native +Linux build, Windows amd64 cross-compiled via mingw-w64 - both from the same +source, in one run). + +**Prerequisites:** Docker - same as the root project, nothing modding-specific +to install locally. + +```sh +cd modding + +# 1. Build the hacked-build-image (once, or after hacked-build-image/ or +# hacked/go.mod|go.sum changes) +./hacked-build-image.sh + +# 2. Compile hacked/ for Linux and Windows +make hacked +``` + +`` is `hacked-`, not an InkyBlackness release version: +`hacked/` is a vendored snapshot here, not its own git checkout, so there is +no tag to derive a real version from (see +`hacked-build-image/build-hacked.sh`'s own comment). + +Run the result: `dist/linux/hacked/hacked` on Linux, +`dist\win\hacked\hacked.exe` on Windows. Needs OpenGL 3.2; usage is documented +on [hacked's own wiki](https://github.com/inkyblackness/hacked/wiki). + +### Publishing the build-image + +Same registry as the root project's own build-image (see the repository +root's `registry.env.example`), under a separate image name so the two don't +collide: + +```sh +./hacked-build-upload-image.sh +``` + +Reads `REGISTRY`/`REGISTRY_USER`/`REGISTRY_PASSWORD` and (optionally) +`HACKED_REGISTRY_IMAGE` from `../registry.env`, same as the root scripts. +Don't bump `hacked-build-image/VERSION` just because the Dockerfile changed - +only once a build with the current version has actually succeeded, same +policy as the root project's own `build-image/VERSION`. diff --git a/modding/hacked-build-image.sh b/modding/hacked-build-image.sh new file mode 100755 index 0000000..dce0014 --- /dev/null +++ b/modding/hacked-build-image.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Builds the hacked build-image (hacked-build-image/Dockerfile) locally, tagged with +# hacked-build-image/VERSION and :latest. Does not push - test it with +# ./hacked-build-run-image.sh first, then publish with +# ./hacked-build-upload-image.sh. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +ROOT="$(pwd)" + +fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; } + +command -v docker >/dev/null 2>&1 \ + || fail "docker not found in PATH" + +IMAGE="${HACKED_REGISTRY_IMAGE:-questshock/hacked-builder}" +if [ -f "$ROOT/../registry.env" ]; then + # shellcheck disable=SC1091 + source "$ROOT/../registry.env" + IMAGE="${HACKED_REGISTRY_IMAGE:-$IMAGE}" +fi + +VERSION="$(<"$ROOT/hacked-build-image/VERSION")" +[ -n "$VERSION" ] || fail "hacked-build-image/VERSION is empty" + +echo "== Building $IMAGE:$VERSION ==" +docker build \ + -f "$ROOT/hacked-build-image/Dockerfile" \ + -t "$IMAGE:$VERSION" \ + -t "$IMAGE:latest" \ + "$ROOT" + +echo "== Done ==" +echo "Image: $IMAGE:$VERSION (and :latest)" +echo "Test it with ./hacked-build-run-image.sh, then publish with ./hacked-build-upload-image.sh" diff --git a/modding/hacked-build-image/Dockerfile b/modding/hacked-build-image/Dockerfile new file mode 100644 index 0000000..d5423dc --- /dev/null +++ b/modding/hacked-build-image/Dockerfile @@ -0,0 +1,64 @@ +# Build environment for modding/hacked/ (vendored from +# https://github.com/inkyblackness/hacked, see ../README.md). Fetches the Go +# toolchain and this module's dependencies once, here, at image-build time, so +# ../hacked-build-run-image.sh can compile fully offline afterward. Rebuild +# with ../hacked-build-image.sh whenever a version below changes, or whenever +# ../hacked/go.mod or go.sum changes (its dependencies are baked into the +# image's module cache). +FROM ubuntu:22.04 + +# go.mod's own "go" directive is a minimum toolchain version, not a version to +# fetch - pin a stable release that already satisfies it so `go build` never +# needs to fetch a newer toolchain over the network at build time. +ARG GO_VERSION=1.27.1 + +# build-essential/pkg-config/curl/ca-certificates/git/gosu: base toolchain. +# mingw-w64: the Windows cross-build. libgl1-mesa-dev/xorg-dev: go-gl/glfw's +# cgo bindings (Linux build only - the Windows cross-build talks to the Win32 +# API directly, via mingw-w64's own headers). libgtk-3-dev: +# github.com/sqweek/dialog's Linux backend uses GTK3 via cgo (`pkg-config +# gtk+-3.0`) for native file/message dialogs. zip: packages the Windows build +# (see build-hacked.sh) - tar, for the Linux .tgz, is already part of the +# base image. +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + build-essential pkg-config curl ca-certificates git gosu \ + mingw-w64 \ + libgl1-mesa-dev xorg-dev \ + libgtk-3-dev \ + zip \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -sSLO "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" \ + && tar -C /usr/local -xzf "go${GO_VERSION}.linux-amd64.tar.gz" \ + && rm "go${GO_VERSION}.linux-amd64.tar.gz" +ENV PATH=/usr/local/go/bin:${PATH} +ENV GOTOOLCHAIN=local +ENV GOPATH=/opt/go + +# Populates /opt/go/pkg/mod with every dependency hacked/go.mod pins, so the +# container never needs network access to build it. Only go.mod/go.sum are +# copied in for this - not the rest of the source - so editing hacked's own +# .go files doesn't invalidate this layer's cache. +COPY hacked/go.mod hacked/go.sum /tmp/hacked-deps/ +RUN cd /tmp/hacked-deps && go mod download && rm -rf /tmp/hacked-deps + +COPY hacked-build-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +COPY hacked-build-image/build-hacked.sh /usr/local/bin/build-hacked.sh +# 755, not +x: the entrypoint drops to a `builder` user matching the *host's* +# UID/GID (see docker-entrypoint.sh), which is never in these root-owned +# files' group, so it needs the image's own explicit world-read+execute here - +# `chmod +x` alone only adds execute bits on top of whatever "other" +# permissions the source file happened to have (host umask-dependent). +RUN chmod 755 /usr/local/bin/docker-entrypoint.sh /usr/local/bin/build-hacked.sh + +WORKDIR /workspace +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] +CMD ["/usr/local/bin/build-hacked.sh"] + +# Marks a shell as already running inside this image, same convention as the +# main project's own build-image (see ../../build-image/Dockerfile) - lets +# ../Makefile compile directly instead of trying to docker-run this image +# again from inside itself (e.g. a CI job already using this image as its +# container). +ENV HACKED_BUILD_IMAGE=1 diff --git a/modding/hacked-build-image/VERSION b/modding/hacked-build-image/VERSION new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/modding/hacked-build-image/VERSION @@ -0,0 +1 @@ +1 diff --git a/modding/hacked-build-image/build-hacked.sh b/modding/hacked-build-image/build-hacked.sh new file mode 100755 index 0000000..1364033 --- /dev/null +++ b/modding/hacked-build-image/build-hacked.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Compiles modding/hacked/ for Linux and Windows (amd64), entirely offline - +# every Go module dependency was already fetched into the build-image at +# image-build time (see Dockerfile). Baked into the image at +# /usr/local/bin/build-hacked.sh (see Dockerfile's CMD) - run via +# ../hacked-build-run-image.sh, which bind-mounts modding/ at /workspace, not +# directly. +# +# Does not follow hacked/build.sh (its own upstream build script): that script +# assumes hacked/ is its own git checkout (it derives its version from `git +# describe --tags`), which isn't true here - hacked/ is a vendored snapshot, +# not a git submodule, so `git describe` there would silently pick up +# *questshock's* tags instead. Version is a plain build-date string instead. +set -euo pipefail + +MODDING_ROOT="$(pwd)" +HACKED_SRC="$MODDING_ROOT/hacked" +OUT_DIR="$MODDING_ROOT/dist" + +VERSION="hacked-$(date -u +%Y%m%d)" +echo "Version: $VERSION" + +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR/linux/hacked" "$OUT_DIR/win/hacked" + +cd "$HACKED_SRC" + +echo "== Building Linux amd64 ==" +go build -gcflags "-d=checkptr=0" -ldflags "-X main.version=$VERSION" \ + -trimpath -o "$OUT_DIR/linux/hacked/hacked" . + +echo "== Preparing the Windows icon/version-info resource ==" +# cmd/link automatically links in a *.syso file sitting in the main package +# directory during a Windows build - no explicit flag needed, but it does mean +# generating it (via windres, from hacked.rc/.ico/.exe.manifest) has to happen +# right here, before go build runs, and be cleaned up after regardless of +# outcome so it never leaks into a Linux build run from the same checkout. +WIN_RES_DIR="$(mktemp -d)" +trap 'rm -rf "$WIN_RES_DIR"; rm -f "$HACKED_SRC/main-res.syso"' EXIT +cp "$HACKED_SRC"/_resources/build/win/* "$WIN_RES_DIR/" +# No git tag to derive a real version from (hacked/ is a vendored snapshot, +# not its own checkout) - same date-based fallback hacked/build.sh itself +# uses when `git describe` fails. +sed -i "s/§MAJOR/$(date -u +%Y)/;s/§MINOR/$(date -u +%m)/;s/§PATCH/$(date -u +%d)/" \ + "$WIN_RES_DIR/hacked.exe.manifest" +x86_64-w64-mingw32-windres -o "$HACKED_SRC/main-res.syso" "$WIN_RES_DIR/hacked.rc" + +echo "== Building Windows amd64 (cross-compiled via mingw-w64) ==" +# -extldflags -static-libgcc/-static-libstdc++: statically links the mingw +# C/C++ runtime into hacked.exe, the same technique the main project's own +# Windows cross-build uses (see ../../build-image/Dockerfile) - avoids having +# to locate and ship libgcc_s_seh-1.dll/libstdc++-6.dll (their exact path +# depends on the mingw-w64 package's gcc version). libwinpthread-1.dll still +# needs shipping alongside the .exe; it isn't part of that static link. +GOOS=windows GOARCH=amd64 CGO_ENABLED=1 \ + CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ \ + go build -gcflags "-d=checkptr=0" \ + -ldflags "-X main.version=$VERSION -H=windowsgui -extldflags '-static-libgcc -static-libstdc++'" \ + -trimpath -o "$OUT_DIR/win/hacked/hacked.exe" . +cp /usr/x86_64-w64-mingw32/lib/libwinpthread-1.dll "$OUT_DIR/win/hacked/" + +echo "== Copying distribution resources ==" +for os in linux win; do + packageDir="$OUT_DIR/$os/hacked" + cp "$HACKED_SRC/LICENSE" "$packageDir/" + cp -R "$HACKED_SRC/_resources/dist/." "$packageDir/" +done + +echo "== Packaging ==" +# $VERSION (hacked-) is already a complete, self-describing name - no +# separate package-name prefix needed on top of it. +(cd "$OUT_DIR/linux" && tar -czf "$OUT_DIR/$VERSION.linux64.tgz" hacked) +(cd "$OUT_DIR/win" && zip -rq "$OUT_DIR/$VERSION.win64.zip" hacked) + +echo "== Done ==" +echo "Linux: $OUT_DIR/$VERSION.linux64.tgz" +echo "Windows: $OUT_DIR/$VERSION.win64.zip" diff --git a/modding/hacked-build-image/docker-entrypoint.sh b/modding/hacked-build-image/docker-entrypoint.sh new file mode 100755 index 0000000..113edde --- /dev/null +++ b/modding/hacked-build-image/docker-entrypoint.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Drops from root (needed to read the Go toolchain and module cache under +# /usr/local/go and /opt/go) to a user matching the host's UID/GID, so +# anything written into the bind-mounted repo (hacked/_build/, ...) is owned +# by the host user instead of root. +set -euo pipefail + +USER_UID="${HOST_UID:-1000}" +USER_GID="${HOST_GID:-1000}" + +groupadd -g "$USER_GID" builder 2>/dev/null || true +useradd -u "$USER_UID" -g "$USER_GID" -m -s /bin/bash builder 2>/dev/null || true + +# /workspace (the bind-mounted repo) may actually be owned by a group the +# host user only has via *supplementary* membership rather than their +# primary GID above - e.g. a VirtualBox vboxsf shared folder, which shows +# up as root:vboxsf on the host and would otherwise be inaccessible to +# `builder` here (falls through to "other", which vboxsf's default mode +# leaves with no permissions at all). Join whatever group actually owns +# /workspace too, if it differs. +WORKSPACE_GID="$(stat -c %g /workspace 2>/dev/null || true)" +if [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "$USER_GID" ]; then + groupadd -g "$WORKSPACE_GID" workspace 2>/dev/null || true + usermod -aG "$WORKSPACE_GID" builder 2>/dev/null || true +fi + +# The Go module cache under /opt/go was populated as root at image-build +# time (see Dockerfile) - readable by anyone, but go build also wants to +# write GOCACHE (build cache) under there by default. Point it at a +# per-run tmpfs-friendly location inside the builder's own home instead of +# fighting permissions on the image's baked-in GOPATH. +export GOCACHE=/home/builder/.cache/go-build +export GOMODCACHE=/opt/go/pkg/mod + +exec gosu builder "$@" diff --git a/modding/hacked-build-run-image.sh b/modding/hacked-build-run-image.sh new file mode 100755 index 0000000..d4bd8d3 --- /dev/null +++ b/modding/hacked-build-run-image.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Runs the hacked build-image against this folder to compile hacked/ (the +# vendored InkyBlackness HackEd snapshot, see README.md) for Linux and +# Windows, entirely offline - every Go module dependency it needs was already +# fetched into the image by ./hacked-build-image.sh. +# +# Output lands in dist/ (both platforms, packaged as .tgz/.zip). +# +# Usage: ./hacked-build-run-image.sh [command...] +# With no arguments, runs the image's baked-in build-hacked.sh (its default +# CMD). Pass a command (e.g. `bash`) to get a shell in the build environment +# instead, useful for debugging a failed build. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +ROOT="$(pwd)" + +fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; } + +command -v docker >/dev/null 2>&1 \ + || fail "docker not found in PATH" + +IMAGE="${HACKED_REGISTRY_IMAGE:-questshock/hacked-builder}" +if [ -f "$ROOT/../registry.env" ]; then + # shellcheck disable=SC1091 + source "$ROOT/../registry.env" + IMAGE="${HACKED_REGISTRY_IMAGE:-$IMAGE}" +fi + +IMAGE_TAG="$(<"$ROOT/hacked-build-image/VERSION")" +[ -n "$IMAGE_TAG" ] || fail "hacked-build-image/VERSION is empty" +IMAGE="$IMAGE:$IMAGE_TAG" + +docker image inspect "$IMAGE" >/dev/null 2>&1 \ + || fail "$IMAGE not found locally - run ./hacked-build-image.sh first" + +TTY_FLAGS="-i" +[ -t 1 ] && TTY_FLAGS="-it" + +# shellcheck disable=SC2086 +docker run --rm $TTY_FLAGS \ + -v "$ROOT:/workspace" \ + -e HOST_UID="$(id -u)" \ + -e HOST_GID="$(id -g)" \ + "$IMAGE" \ + "$@" diff --git a/modding/hacked-build-upload-image.sh b/modding/hacked-build-upload-image.sh new file mode 100755 index 0000000..8897874 --- /dev/null +++ b/modding/hacked-build-upload-image.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Pushes the hacked build-image - already built locally with +# ./hacked-build-image.sh, and ideally verified with +# ./hacked-build-run-image.sh - to a container registry, so CI or other +# machines can reuse it without rebuilding the whole toolchain. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +ROOT="$(pwd)" + +fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; } + +command -v docker >/dev/null 2>&1 \ + || fail "docker not found in PATH" + +[ -f "$ROOT/../registry.env" ] \ + || fail "../registry.env not found - copy ../registry.env.example to ../registry.env and fill in your registry credentials" + +# shellcheck disable=SC1091 +source "$ROOT/../registry.env" + +: "${REGISTRY:?registry.env must set REGISTRY}" +: "${REGISTRY_USER:?registry.env must set REGISTRY_USER}" +: "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}" +HACKED_REGISTRY_IMAGE="${HACKED_REGISTRY_IMAGE:-questshock/hacked-builder}" + +VERSION="$(<"$ROOT/hacked-build-image/VERSION")" +[ -n "$VERSION" ] || fail "hacked-build-image/VERSION is empty" + +docker image inspect "$HACKED_REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \ + || fail "$HACKED_REGISTRY_IMAGE:$VERSION not found locally - run ./hacked-build-image.sh first" + +echo "== Logging in to $REGISTRY ==" +echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin + +echo "== Pushing $HACKED_REGISTRY_IMAGE:$VERSION and :latest ==" +docker push "$HACKED_REGISTRY_IMAGE:$VERSION" +docker push "$HACKED_REGISTRY_IMAGE:latest" + +echo "== Done ==" +echo "Image: $HACKED_REGISTRY_IMAGE:$VERSION" diff --git a/modding/hacked/.gitattributes b/modding/hacked/.gitattributes new file mode 100644 index 0000000..314766e --- /dev/null +++ b/modding/hacked/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.{cmd,[cC][mM][dD]} text eol=crlf +*.{bat,[bB][aA][tT]} text eol=crlf diff --git a/modding/hacked/.gitignore b/modding/hacked/.gitignore new file mode 100644 index 0000000..161cf9a --- /dev/null +++ b/modding/hacked/.gitignore @@ -0,0 +1,5 @@ +.idea/ +_build/ + +imgui.ini +*.syso diff --git a/modding/hacked/.golangci.yml b/modding/hacked/.golangci.yml new file mode 100644 index 0000000..d99e540 --- /dev/null +++ b/modding/hacked/.golangci.yml @@ -0,0 +1,84 @@ +run: + deadline: 10m + +linters: + enable: + - asciicheck + - bodyclose + - deadcode + - depguard + - dogsled + - dupl + - durationcheck + - errcheck + - errorlint + - exportloopref + - forbidigo + - goconst + - gocritic + - godot + - godox + - goerr113 + - gofmt + - goheader + - gomoddirectives + - gomodguard + - goprintffuncname + - gosimple + - govet + - importas + - ineffassign + - lll + - makezero + - misspell + - nilerr + - noctx + - nolintlint + - predeclared + - promlinter + - revive + - rowserrcheck + - sqlclosecheck + - staticcheck + - structcheck + - tagliatelle + - testpackage + - thelper + - tparallel + - typecheck + - unconvert + - unused + - varcheck + - wastedassign + - whitespace + + # linters are disabled if their majority of issues is considered false-positive (intended code) + # and the remaining issues (if existing) aren't worth it. + disable: + - gochecknoglobals + - gochecknoinits + - goimports # disabled because of so many false-positives with "imgui-go" + - gosec + - maligned + - nakedret + - prealloc + - unparam + - wsl + - funlen + - gocyclo # disabled for UI code + - nestif # disabled for UI code + - gocognit + - stylecheck # enable again for doing all the package documentation. + - gomnd # too many false positives with constants themselves. + +issues: + exclude: + # too often for OpenGL constants + - don't use ALL_CAPS in Go names + exclude-use-default: false # disable filtering of defaults for better zero-issue policy + max-per-linter: 0 # disable limit; report all issues of a linter + max-same-issues: 0 # disable limit; report all issues of the same issue + +linters-settings: + lll: + line-length: 160 diff --git a/modding/hacked/CONTRIBUTING.md b/modding/hacked/CONTRIBUTING.md new file mode 100644 index 0000000..bda74ca --- /dev/null +++ b/modding/hacked/CONTRIBUTING.md @@ -0,0 +1,38 @@ +## Contributing Guidelines + +Thank you for considering to contribute to this application! + +The following text lists guidelines for contributions. +These guidelines don't have legal status, so use them as a reference and common sense - and feel free to update them as well! + + +### "I just want to know..." + +For questions, or general usage-information about HackEd, please refer to the [systemshock.org](https://systemshock.org) forums (`Engineering` subforum for modding in general, `Deck 13` for source development, including engine code), or refer to the [Wiki](https://github.com/inkyblackness/hacked/wiki). + +You might also find information on the data files in the [ss-specs](https://github.com/inkyblackness/ss-specs) project. + + +### Scope + +This is an editor for mods and fan-missions of the video game System Shock (1994, and compatible), written in Go. + +If there are extensions to the data files, please be sure that these were discussed and/or documented in `ss-specs`. Usability features are always welcome, I only ask to give them a second thought if there might be a more generic solution (i.e., don't immediately jump to the implementation of "I need X", rather think how a slightly different feature might both give you X and also Y in even a simpler form...) + +Please also consider engine compatibility. If there are engine features not supported by all (especially the classic version), please make sure these are indicated to the user. Also refer to the documentation on the Wiki for such cases. + + +### Code Style + +Please make sure code is formatted according to `go fmt`, and use the following linter: [golangci-lint](https://github.com/golangci/golangci-lint). + +> If there are linter errors that you didn't introduce, you don't have to clean them up - I might have missed them and will be handling them separately. + + +### Testing Style + +The code under `ss1` should be well tested, please try to keep up coverage. The code under `editor` is tricky to test - if you figure out how to test ImGui based stuff, I'd be happy for a pull request! At least utilities should have tests. + +> I am aiming for a coverage of over 90% in there. + +For testing use either the vanilla `testing` package, or augment it with the `github.com/stretchr/testify` framework. See existing examples. diff --git a/modding/hacked/LICENSE b/modding/hacked/LICENSE new file mode 100644 index 0000000..f5722bf --- /dev/null +++ b/modding/hacked/LICENSE @@ -0,0 +1,27 @@ +New BSD License + +© 2018, Christian Haas +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of "InkyBlackness" nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE LISTED COPYRIGHT HOLDERS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/modding/hacked/README.md b/modding/hacked/README.md new file mode 100644 index 0000000..b8ff4d5 --- /dev/null +++ b/modding/hacked/README.md @@ -0,0 +1,28 @@ +# InkyBlackness HackEd + +HackEd is the main graphical editor application as part of the [InkyBlackness](https://inkyblackness.github.io) project, written in [Go](http://golang.org/). It allows editing of game files of the original System Shock (1994 and compatible) to create mods and fan-missions. + +This application is the successor of the `deck` package of InkyBlackness, which provided the first public editor, even before the source release of the original engine. The rewrite was necessary due to several factors. + +The [releases page](https://github.com/inkyblackness/hacked/releases) provides the latest binaries for both MS Windows and Linux. + +## Documentation + +Usage of the editor is documented on its [Wiki](https://github.com/inkyblackness/hacked/wiki) page. +Answers and further help about modding can be furthermore found on the [systemshock.org](https://systemshock.org) forums, particularly the `Engineering` subforum. + +## Screenshots + +Level editing details: +![v1.0.0 Level Editing](https://user-images.githubusercontent.com/1508944/43354141-b8c536b0-9246-11e8-8014-c306be4ab1bb.png) + +Media editing details: +![v1.0.0 Media Editing](https://user-images.githubusercontent.com/1508944/43354142-c3035742-9246-11e8-86d8-31cf62b7ca1b.png) + +4K glory showing all the windows: +![v1.0.0 4K Glory](https://user-images.githubusercontent.com/1508944/43354138-a4676cc4-9246-11e8-883f-46ddde4aba25.png) + + +## License + +The project is available under the terms of the **New BSD License** (see LICENSE file). diff --git a/modding/hacked/_resources/build/win/hacked.exe.manifest b/modding/hacked/_resources/build/win/hacked.exe.manifest new file mode 100644 index 0000000..35c4079 --- /dev/null +++ b/modding/hacked/_resources/build/win/hacked.exe.manifest @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/modding/hacked/_resources/build/win/hacked.ico b/modding/hacked/_resources/build/win/hacked.ico new file mode 100755 index 0000000000000000000000000000000000000000..5816c1a8a1966248ae0645362911178935c1ce74 GIT binary patch literal 59591 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/_resources/build/win/hacked.rc b/modding/hacked/_resources/build/win/hacked.rc new file mode 100644 index 0000000..5f964df --- /dev/null +++ b/modding/hacked/_resources/build/win/hacked.rc @@ -0,0 +1,3 @@ +100 ICON "hacked.ico" +100 24 "hacked.exe.manifest" +GLFW_ICON ICON "hacked.ico" diff --git a/modding/hacked/_resources/dist/README.md b/modding/hacked/_resources/dist/README.md new file mode 100644 index 0000000..71eb17e --- /dev/null +++ b/modding/hacked/_resources/dist/README.md @@ -0,0 +1,17 @@ +# InkyBlackness - HackEd + +This package contains the public distribution of the [InkyBlackness](https://inkyblackness.github.io) HackEd binary. + +The executable is a modding tool for the DOS game [System Shock (1994)](http://en.wikipedia.org/wiki/System_Shock) and compatibles. + +## Disclaimer +A general warning: Keep backups of your files! +This is an executables downloaded from the internet. If you are unsure what they do or how they work, refer to the sources [on GitHub](https://github.com/inkyblackness). + + +## Requirements + +HackEd is a GUI editor for the resources, including a map editor. +The tool uses OpenGL 3.2 . + +User documentation is available online here: https://github.com/inkyblackness/hacked/wiki diff --git a/modding/hacked/build.sh b/modding/hacked/build.sh new file mode 100755 index 0000000..89e5b2d --- /dev/null +++ b/modding/hacked/build.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +pushd $(dirname "${0}") >/dev/null +HACKED_BASE=$(pwd -L) +FOLDER_NAME=inkyblackness-hacked + +echo "Cleaning output directories..." +rm -rf _build +rm main-res.syso + +mkdir -p $HACKED_BASE/_build/linux/$FOLDER_NAME +mkdir -p $HACKED_BASE/_build/win/$FOLDER_NAME + +echo "Determining version" + +MAJOR=$(date +%Y) +MINOR=$(date +%m) +PATCH=$(date +%d) +VERSION=$(git describe --tags) +if [ $? -ne 0 ]; then + echo "Could not determine tag, defaulting to revision for version" + REV=$(git rev-parse --short HEAD) + VERSION="rev$REV" +else + VERSION_RAW=$(echo "$VERSION" | cut -d'-' -f 1 | cut -d'v' -f 2) +fi + +EXTRA=$(echo "$VERSION" | cut -d'-' -f 2) +if [[ "$VERSION" == "$EXTRA" ]]; then + MAJOR=$(echo "$VERSION_RAW" | cut -d'.' -f 1) + MINOR=$(echo "$VERSION_RAW" | cut -d'.' -f 2) + PATCH=$(echo "$VERSION_RAW" | cut -d'.' -f 3) +fi +echo "Determined version: $VERSION ($MAJOR.$MINOR.$PATCH)" + +echo "Preparing build resources" +mkdir -p $HACKED_BASE/_build/win_temp +cp $HACKED_BASE/_resources/build/win/* $HACKED_BASE/_build/win_temp +sed -i "s/§MAJOR/$MAJOR/g" $HACKED_BASE/_build/win_temp/hacked.exe.manifest +sed -i "s/§MINOR/$MINOR/g" $HACKED_BASE/_build/win_temp/hacked.exe.manifest +sed -i "s/§PATCH/$PATCH/g" $HACKED_BASE/_build/win_temp/hacked.exe.manifest +x86_64-w64-mingw32-windres -o main-res.syso $HACKED_BASE/_build/win_temp/hacked.rc + +echo "Building executables..." +go build -gcflags "-d=checkptr=0" -ldflags "-X main.version=$VERSION" -a -o $HACKED_BASE/_build/linux/$FOLDER_NAME/hacked -trimpath $(pwd) . +GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CXX=x86_64-w64-mingw32-g++ CC=x86_64-w64-mingw32-gcc go build -gcflags "-d=checkptr=0" -ldflags "-X main.version=$VERSION -H=windowsgui" -a -o $HACKED_BASE/_build/win/$FOLDER_NAME/hacked.exe -trimpath $(pwd) . + +echo "Copying distribution resources..." + +for os in "linux" "win"; do + packageDir=$HACKED_BASE/_build/$os/$FOLDER_NAME + + cp $HACKED_BASE/LICENSE $packageDir + cp -R $HACKED_BASE/_resources/dist/* $packageDir +done + +for lib in "libgcc_s_seh-1.dll" "libstdc++-6.dll" "libwinpthread-1.dll"; do + for base in "/usr/x86_64-w64-mingw32/bin" "/usr/lib/gcc/x86_64-w64-mingw32/10-win32" "/usr/x86_64-w64-mingw32/lib"; do + if [ -e $base/$lib ]; then + cp $base/$lib $HACKED_BASE/_build/win/$FOLDER_NAME + fi + done + if [ ! -f $HACKED_BASE/_build/win/$FOLDER_NAME/$lib ]; then + echo " FAIL: file $lib is nowhere found" + fi +done + +echo "Creating packages..." + +cd $HACKED_BASE/_build/linux +tar -cvzf $HACKED_BASE/_build/$FOLDER_NAME-$VERSION.linux64.tgz ./$FOLDER_NAME + +cd $HACKED_BASE/_build/win +zip -r $HACKED_BASE/_build/$FOLDER_NAME-$VERSION.win64.zip . + +popd >/dev/null diff --git a/modding/hacked/crash/Handler.go b/modding/hacked/crash/Handler.go new file mode 100644 index 0000000..25dba1c --- /dev/null +++ b/modding/hacked/crash/Handler.go @@ -0,0 +1,4 @@ +package crash + +// Handler is a function that tries to preserve some panic/stack info to the user. +var Handler = func(versionInfo string) {} diff --git a/modding/hacked/crash/Handler_windows.go b/modding/hacked/crash/Handler_windows.go new file mode 100644 index 0000000..6f8f9ea --- /dev/null +++ b/modding/hacked/crash/Handler_windows.go @@ -0,0 +1,42 @@ +package crash + +//#include +import "C" +import ( + "fmt" + "runtime/debug" + "unsafe" +) + +func init() { + Handler = windowsHandler +} + +func windowsHandler(versionInfo string) { + if e := recover(); e != nil { + text := `There was a crash in the application. This is bad and I am sorry. + +Perhaps you can make something with the details of the error below. +If you can reproduce this, please make a screenshot of this box and report it with details on the http://www.systemshock.org forums. +Thank you! + +` + text += fmt.Sprintf("%s:\n%s", e, debug.Stack()) + messageBox(text, "Something unexpected happened - "+versionInfo) + } +} + +func messageBox(text, caption string) { + textArg, textFin := wrapString(text) + defer textFin() + captionArg, captionFin := wrapString(caption) + defer captionFin() + var hwnd unsafe.Pointer + C.MessageBoxExA((*C.struct_HWND__)(hwnd), textArg, captionArg, C.UINT(0), C.WORD(0)) +} + +func wrapString(value string) (wrapped *C.char, finisher func()) { + wrapped = C.CString(value) + finisher = func() { C.free(unsafe.Pointer(wrapped)) } // nolint: gas + return +} diff --git a/modding/hacked/editor/Application.go b/modding/hacked/editor/Application.go new file mode 100644 index 0000000..fe2b42c --- /dev/null +++ b/modding/hacked/editor/Application.go @@ -0,0 +1,945 @@ +package editor + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "path/filepath" + "strings" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/about" + "github.com/inkyblackness/hacked/editor/animations" + "github.com/inkyblackness/hacked/editor/archives" + "github.com/inkyblackness/hacked/editor/bitmaps" + "github.com/inkyblackness/hacked/editor/external" + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/levels" + "github.com/inkyblackness/hacked/editor/messages" + "github.com/inkyblackness/hacked/editor/movies" + "github.com/inkyblackness/hacked/editor/objects" + "github.com/inkyblackness/hacked/editor/project" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/editor/sounds" + "github.com/inkyblackness/hacked/editor/texts" + "github.com/inkyblackness/hacked/editor/textures" + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/content/sound" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/media" + "github.com/inkyblackness/hacked/ss1/edit/undoable" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/gui" + "github.com/inkyblackness/hacked/ui/input" + "github.com/inkyblackness/hacked/ui/opengl" +) + +const ( + errCouldNotOpenFile ss1.StringError = "could not open file" + errCouldNotReadFile ss1.StringError = "could not read file" +) + +type projectState struct { + ProjectSettings *edit.ProjectSettings `json:",omitempty"` + GameStateSettings *edit.GameStateSettings `json:",omitempty"` + OpenWindows []string `json:",omitempty"` + ActiveLevelIndex *int `json:",omitempty"` +} + +func projectStateFromFile(filename string) (projectState, error) { + data, err := ioutil.ReadFile(filename) + if err != nil { + return projectState{}, errCouldNotOpenFile + } + var state projectState + err = json.Unmarshal(data, &state) + if err != nil { + return projectState{}, errCouldNotReadFile + } + return state, nil +} + +// SaveTo stores the state in a file with given filename. +func (state projectState) SaveTo(filename string) error { + bytes, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return ioutil.WriteFile(filename, bytes, 0640) +} + +type lastProjectState struct { + projectState + LastProject string `json:",omitempty"` +} + +func lastProjectStateFromFile(filename string) lastProjectState { + data, err := ioutil.ReadFile(filename) + if err != nil { + return lastProjectState{} + } + var state lastProjectState + err = json.Unmarshal(data, &state) + if err != nil { + return lastProjectState{} + } + return state +} + +// SaveTo stores the state in a file with given filename. +func (state lastProjectState) SaveTo(filename string) error { + bytes, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return ioutil.WriteFile(filename, bytes, 0640) +} + +// Application is the root object of the graphical editor. +// It is set up by the main method. +type Application struct { + // Version identifies the build of the application. + Version string + + // ConfigDir is the base path to store configuration in. + ConfigDir string + + window opengl.Window + clipboard clipboardAdapter + gl opengl.OpenGL + + // FontFile specifies the font to use. + FontFile string + // FontSize specifies the font size to use. + FontSize float32 + // GuiScale is applied when the window is initialized. + GuiScale float32 + guiContext *gui.Context + + lastModifier input.Modifier + lastMouseX float32 + lastMouseY float32 + + txnBuilder cmd.TransactionBuilder + cmdStack *cmd.Stack + mod *world.Mod + cp text.Codepage + textLineCache *text.Cache + textPageCache *text.Cache + messagesCache *text.ElectronicMessageCache + paletteCache *graphics.PaletteCache + textureCache *graphics.TextureCache + frameCache *graphics.FrameCache + animationCache *bitmap.AnimationCache + movieCache *movie.Cache + soundEffectCache *sound.EffectCache + + mapDisplay *levels.MapDisplay + + levels *edit.EditableLevels + levelSelection *edit.LevelSelectionService + levelEditorService *edit.LevelEditorService + + projectService *edit.ProjectService + gameStateService *edit.GameStateService + gameObjectsService *edit.GameObjectsService + + projectView *project.View + archiveView *archives.View + levelControlView *levels.ControlView + levelTilesView *levels.TilesView + levelObjectsView *levels.ObjectsView + messagesView *messages.View + textsView *texts.View + bitmapsView *bitmaps.View + texturesView *textures.View + animationsView *animations.View + moviesView *movies.View + soundEffectsView *sounds.View + objectsView *objects.View + aboutView *about.View + licensesView *about.LicensesView + + modalState gui.ModalStateWrapper + + failureMessage string + failurePending bool +} + +// InitializeWindow takes the given window and attaches the callbacks. +func (app *Application) InitializeWindow(window opengl.Window) (err error) { + app.window = window + app.clipboard.window = window + app.gl = window.OpenGL() + + app.window.RestoreState(opengl.WindowStateFromFile(app.windowStateConfigFilename(), app.window.StateSnapshot())) + + app.initSignalling() + app.initWindowCallbacks() + app.initOpenGL() + + app.initModel() + err = app.initGui() + if err != nil { + return + } + app.initView() + app.restoreWorkspace() + + app.onWindowResize(app.window.Size()) + + return +} + +func (app *Application) onWindowClosed() { + if app.guiContext != nil { + app.guiContext.Destroy() + app.guiContext = nil + } +} + +func (app *Application) initWindowCallbacks() { + app.window.OnClosing(app.onWindowClosing) + app.window.OnClosed(app.onWindowClosed) + app.window.OnFileDropCallback(app.onFilesDropped) + + app.window.OnResize(app.onWindowResize) + + app.window.OnKeyPress(app.onKeyPress) + app.window.OnKeyRelease(app.onKeyRelease) + app.window.OnCharCallback(app.onChar) + app.window.OnModifier(app.onModifier) + + app.window.OnMouseMove(app.onMouseMove) + app.window.OnMouseScroll(app.onMouseScroll) + app.window.OnMouseButtonDown(app.onMouseButtonDown) + app.window.OnMouseButtonUp(app.onMouseButtonUp) + + app.window.OnRender(app.render) +} + +func (app *Application) render() { + app.guiContext.NewFrame() + + width, height := app.window.Size() + app.gl.Viewport(0, 0, int32(width), int32(height)) + app.gl.ClearColor(0.0, 0.0, 0.0, 1.0) + app.gl.Clear(opengl.COLOR_BUFFER_BIT) + + paletteTexture, _ := app.paletteCache.Palette(0) + app.mapDisplay.Render( + paletteTexture, app.textureCache.Texture, + app.levelTilesView.TextureDisplay(), app.levelTilesView.ColorDisplay()) + + app.handleFailure() + app.renderMainMenu() + + app.projectView.Render() + app.updateAutoSave() + + app.archiveView.Render() + app.levelControlView.Render() + app.levelTilesView.Render() + app.levelObjectsView.Render() + app.messagesView.Render() + app.textsView.Render() + app.bitmapsView.Render() + app.texturesView.Render() + app.animationsView.Render() + app.moviesView.Render() + app.soundEffectsView.Render() + app.objectsView.Render() + app.aboutView.Render() + app.licensesView.Render() + + app.modalState.Render() + + app.guiContext.Render(app.bitmapTextureForUI) +} + +func (app *Application) initOpenGL() { + app.gl.Disable(opengl.DEPTH_TEST) + app.gl.Enable(opengl.BLEND) + app.gl.BlendFunc(opengl.SRC_ALPHA, opengl.ONE_MINUS_SRC_ALPHA) +} + +func (app *Application) initGui() (err error) { + app.initGuiSizes() + param := gui.ContextParameters{ + ConfigDir: app.ConfigDir, + FontFile: app.FontFile, + FontSize: app.FontSize, + } + app.guiContext, err = gui.NewContext(app.window, param) + if err != nil { + return + } + + imguiIO := imgui.CurrentIO() + imguiIO.KeyMap(imgui.KeyTab, int(input.KeyTab)) + imguiIO.KeyMap(imgui.KeyLeftArrow, int(input.KeyLeft)) + imguiIO.KeyMap(imgui.KeyRightArrow, int(input.KeyRight)) + imguiIO.KeyMap(imgui.KeyUpArrow, int(input.KeyUp)) + imguiIO.KeyMap(imgui.KeyDownArrow, int(input.KeyDown)) + imguiIO.KeyMap(imgui.KeyPageUp, int(input.KeyPageUp)) + imguiIO.KeyMap(imgui.KeyPageDown, int(input.KeyPageDown)) + imguiIO.KeyMap(imgui.KeyHome, int(input.KeyHome)) + imguiIO.KeyMap(imgui.KeyEnd, int(input.KeyEnd)) + imguiIO.KeyMap(imgui.KeyInsert, int(input.KeyInsert)) + imguiIO.KeyMap(imgui.KeyDelete, int(input.KeyDelete)) + imguiIO.KeyMap(imgui.KeyBackspace, int(input.KeyBackspace)) + imguiIO.KeyMap(imgui.KeyEnter, int(input.KeyEnter)) + imguiIO.KeyMap(imgui.KeyEscape, int(input.KeyEscape)) + imguiIO.KeyMap(imgui.KeyA, int(input.KeyA)) + imguiIO.KeyMap(imgui.KeyC, int(input.KeyC)) + imguiIO.KeyMap(imgui.KeyV, int(input.KeyV)) + imguiIO.KeyMap(imgui.KeyX, int(input.KeyX)) + + app.initGuiStyle() + + app.mapDisplay = levels.NewMapDisplay(app.gameObjectsService, app.levelSelection, app.levelEditorService, + app.gl, app.GuiScale, + app.gameTexture) + + return +} + +func (app *Application) gameTexture(index level.TextureIndex) (*graphics.BitmapTexture, error) { + key := resource.KeyOf(ids.LargeTextures.Plus(int(index)), resource.LangAny, 0) + return app.textureCache.Texture(key) +} + +func (app *Application) bitmapTextureForUI(textureID imgui.TextureID) (palette uint32, texture uint32) { + idType := byte(textureID >> 48) + switch idType { + case render.BitmapTextureTypeResource: + paletteTexture, _ := app.paletteCache.Palette(0) + if paletteTexture == nil { + return 0, 0 + } + + lang := resource.Language((textureID >> 32) & 0xFF) + resourceID := resource.ID((textureID >> 16) & 0xFFFF) + blockIndex := int(textureID & 0xFFFF) + key := resource.KeyOf(resourceID, lang, blockIndex) + tex, err := app.textureCache.Texture(key) + if err != nil { + return 0, 0 + } + return paletteTexture.Handle(), tex.Handle() + case render.BitmapTextureTypeFrame: + key := graphics.FrameCacheKey(textureID & 0xFFFF) + return app.frameCache.HandlesForKey(key) + } + return 0, 0 +} + +func (app *Application) onWindowClosing() { + windowState := app.window.StateSnapshot() + _ = windowState.SaveTo(app.windowStateConfigFilename()) + + app.saveWorkspace() +} + +func (app *Application) onWindowResize(width int, height int) { + app.mapDisplay.WindowResized(width, height) + app.gl.Viewport(0, 0, int32(width), int32(height)) +} + +func (app *Application) onFilesDropped(names []string) { + app.modalState.HandleFiles(names) +} + +func (app *Application) onKeyPress(key input.Key, modifier input.Modifier) { + app.lastModifier = modifier + + usedGlobalShortcut := true + switch { + case key == input.KeyEscape: + app.modalState.SetState(nil) + case key == input.KeyUndo: + app.tryUndo() + case key == input.KeyRedo: + app.tryRedo() + case key == input.KeyNew: + app.newProject() + case key == input.KeySave: + app.projectView.StartSavingMod() + case key == input.KeyF1 && modifier.IsClear(): + *app.projectView.WindowOpen() = !*app.projectView.WindowOpen() + case key == input.KeyF2 && modifier.IsClear(): + *app.levelControlView.WindowOpen() = !*app.levelControlView.WindowOpen() + case key == input.KeyF3 && modifier.IsClear(): + *app.levelTilesView.WindowOpen() = !*app.levelTilesView.WindowOpen() + case key == input.KeyF4 && modifier.IsClear(): + *app.levelObjectsView.WindowOpen() = !*app.levelObjectsView.WindowOpen() + case key == input.KeyF5 && modifier.IsClear(): + *app.messagesView.WindowOpen() = !*app.messagesView.WindowOpen() + default: + usedGlobalShortcut = false + } + if usedGlobalShortcut { + return + } + + if app.shouldReportKeyToImgui(key) { + imgui.CurrentIO().KeyPress(int(key)) + app.updateKeyModifier() + } +} + +func (app *Application) onKeyRelease(key input.Key, modifier input.Modifier) { + app.lastModifier = modifier + + if app.shouldReportKeyToImgui(key) { + imgui.CurrentIO().KeyRelease(int(key)) + app.updateKeyModifier() + } +} + +func (app *Application) shouldReportKeyToImgui(key input.Key) bool { + keyIsModifier := key.AsModifier() != input.ModNone + return keyIsModifier || app.guiContext.IsUsingKeyboard() +} + +func (app *Application) onChar(char rune) { + if app.guiContext.IsUsingKeyboard() { + imgui.CurrentIO().AddInputCharacters(string(char)) + return + } + + switch char { + case 'v': + _ = app.levelEditorService.PlaceObjectsOnFloor() + case 'f': + _ = app.levelEditorService.PlaceObjectsOnEyeLevel() + case 'r': + _ = app.levelEditorService.PlaceObjectsOnCeiling() + } +} + +func (app *Application) updateKeyModifier() { + imguiIO := imgui.CurrentIO() + imguiIO.KeyCtrl(int(input.KeyControl), int(input.KeyControl)) + imguiIO.KeyShift(int(input.KeyShift), int(input.KeyShift)) + imguiIO.KeyAlt(int(input.KeyAlt), int(input.KeyAlt)) + imguiIO.KeySuper(int(input.KeySuper), int(input.KeySuper)) +} + +func (app *Application) onModifier(modifier input.Modifier) { + app.lastModifier = modifier +} + +func (app *Application) modalActive() bool { + return (app.modalState.State != nil) || (len(app.failureMessage) > 0) +} + +func (app *Application) tryUndo() { + if !app.cmdStack.CanUndo() || app.modalActive() { + return + } + imgui.ClearActiveID() + err := app.projectService.ModifyModWith(app.cmdStack.Undo) + if err != nil { + app.onFailure("Undo", "", err) + } +} + +func (app *Application) tryRedo() { + if !app.cmdStack.CanRedo() || app.modalActive() { + return + } + imgui.ClearActiveID() + err := app.projectService.ModifyModWith(app.cmdStack.Redo) + if err != nil { + app.onFailure("Redo", "", err) + } +} + +func (app *Application) onMouseMove(x, y float32) { + app.lastMouseX = x + app.lastMouseY = y + app.guiContext.SetMousePosition(x, y) + if !app.guiContext.IsUsingMouse() { + app.mapDisplay.MouseMoved(x, y) + } +} + +func (app *Application) onMouseScroll(dx, dy float32) { + if !app.guiContext.IsUsingMouse() { + app.mapDisplay.MouseScrolled(app.lastMouseX, app.lastMouseY, dx, dy, app.lastModifier) + } + app.guiContext.MouseScroll(dx, dy) +} + +func (app *Application) onMouseButtonDown(buttonMask uint32, modifier input.Modifier) { + if !app.guiContext.IsUsingMouse() { + app.mapDisplay.MouseButtonDown(app.lastMouseX, app.lastMouseY, buttonMask) + } + app.reportButtonChange(buttonMask, true) +} + +func (app *Application) onMouseButtonUp(buttonMask uint32, modifier input.Modifier) { + if !app.guiContext.IsUsingMouse() { + app.mapDisplay.MouseButtonUp(app.lastMouseX, app.lastMouseY, buttonMask, modifier) + } + app.reportButtonChange(buttonMask, false) +} + +func (app *Application) reportButtonChange(buttonMask uint32, down bool) { + for buttonIndex := 0; buttonIndex < 32; buttonIndex++ { + if (buttonMask & (1 << uint32(buttonIndex))) != 0 { + app.guiContext.MouseButtonChanged(buttonIndex, down) + } + } +} + +func (app *Application) initGuiSizes() { + if app.GuiScale < 0.5 { + app.GuiScale = 1.0 + } else if app.GuiScale > 10.0 { + app.GuiScale = 10.0 + } + + if app.FontSize <= 0 { + app.FontSize = 16.0 + } + + app.FontSize *= app.GuiScale +} + +func (app *Application) initGuiStyle() { + if len(app.FontFile) == 0 { + imgui.CurrentIO().SetFontGlobalScale(app.GuiScale) + } + imgui.CurrentStyle().ScaleAllSizes(app.GuiScale) + + color := func(r, g, b byte, alpha float32) imgui.Vec4 { + return imgui.Vec4{X: float32(r) / 255.0, Y: float32(g) / 255.0, Z: float32(b) / 255.0, W: alpha} + } + colorDoubleFull := func(alpha float32) imgui.Vec4 { return color(0xC4, 0x38, 0x9F, alpha) } + colorDoubleDark := func(alpha float32) imgui.Vec4 { return color(0x31, 0x01, 0x38, alpha) } + + colorTripleFull := func(alpha float32) imgui.Vec4 { return color(0x21, 0xFF, 0x43, alpha) } + colorTripleDark := func(alpha float32) imgui.Vec4 { return color(0x06, 0xCC, 0x94, alpha) } + colorTripleLight := func(alpha float32) imgui.Vec4 { return color(0x51, 0x99, 0x58, alpha) } + + style := imgui.CurrentStyle() + style.SetColor(imgui.StyleColorText, colorTripleFull(1.0)) + style.SetColor(imgui.StyleColorTextDisabled, colorTripleDark(1.0)) + + style.SetColor(imgui.StyleColorWindowBg, colorDoubleDark(0.80)) + style.SetColor(imgui.StyleColorPopupBg, colorDoubleDark(0.75)) + + style.SetColor(imgui.StyleColorTitleBgActive, colorTripleLight(1.0)) + style.SetColor(imgui.StyleColorFrameBg, colorTripleLight(0.54)) + + style.SetColor(imgui.StyleColorFrameBgHovered, colorTripleDark(0.4)) + style.SetColor(imgui.StyleColorFrameBgActive, colorTripleDark(0.67)) + style.SetColor(imgui.StyleColorCheckMark, colorTripleDark(1.0)) + style.SetColor(imgui.StyleColorSliderGrabActive, colorTripleDark(1.0)) + style.SetColor(imgui.StyleColorButton, colorTripleDark(0.4)) + style.SetColor(imgui.StyleColorButtonHovered, colorTripleDark(1.0)) + style.SetColor(imgui.StyleColorHeader, colorTripleLight(0.70)) + style.SetColor(imgui.StyleColorHeaderHovered, colorTripleDark(0.8)) + style.SetColor(imgui.StyleColorHeaderActive, colorTripleDark(1.0)) + style.SetColor(imgui.StyleColorResizeGrip, colorTripleDark(0.25)) + style.SetColor(imgui.StyleColorResizeGripHovered, colorTripleDark(0.67)) + style.SetColor(imgui.StyleColorResizeGripActive, colorTripleDark(0.95)) + style.SetColor(imgui.StyleColorTextSelectedBg, colorTripleDark(0.35)) + + style.SetColor(imgui.StyleColorTab, colorTripleLight(0.54)) + style.SetColor(imgui.StyleColorTabHovered, colorTripleLight(0.75)) + style.SetColor(imgui.StyleColorTabActive, colorTripleLight(1.0)) + + style.SetColor(imgui.StyleColorSliderGrab, colorDoubleFull(1.0)) + style.SetColor(imgui.StyleColorButtonActive, colorDoubleFull(1.0)) + style.SetColor(imgui.StyleColorSeparatorHovered, colorDoubleFull(0.78)) + style.SetColor(imgui.StyleColorSeparatorActive, colorTripleLight(1.0)) +} + +func (app *Application) initSignalling() { + app.cmdStack = new(cmd.Stack) + app.txnBuilder.Commander = app +} + +func (app *Application) initModel() { + app.mod = world.NewMod(app.resourcesChanged, app.modReset) + + app.cp = text.DefaultCodepage() + app.textLineCache = text.NewLineCache(app.cp, app.mod) + app.textPageCache = text.NewPageCache(app.cp, app.mod) + app.messagesCache = text.NewElectronicMessageCache(app.cp, app.mod) + app.movieCache = movie.NewCache(app.cp, app.mod) + app.soundEffectCache = sound.NewEffectCache(app.mod) + + app.levels = edit.NewEditableLevels(&app.txnBuilder, app.mod) + app.levelSelection = edit.NewLevelSelectionService(app.levels) + + app.gameObjectsService = edit.NewGameObjectsService(app.mod) + app.levelEditorService = edit.NewLevelEditorService(&app.txnBuilder, app.gameObjectsService, app.levels, app.levelSelection) + + app.paletteCache = graphics.NewPaletteCache(app.gl, app.mod) + app.textureCache = graphics.NewTextureCache(app.gl, app.mod) + app.frameCache = graphics.NewFrameCache(app.gl) + app.animationCache = bitmap.NewAnimationCache(app.mod) +} + +func (app *Application) resourcesChanged(modifiedIDs []resource.ID, failedIDs []resource.ID) { + app.textLineCache.InvalidateResources(modifiedIDs) + app.textPageCache.InvalidateResources(modifiedIDs) + app.messagesCache.InvalidateResources(modifiedIDs) + app.movieCache.InvalidateResources(modifiedIDs) + app.soundEffectCache.InvalidateResources(modifiedIDs) + app.levels.InvalidateResources(modifiedIDs) + app.paletteCache.InvalidateResources(modifiedIDs) + app.textureCache.InvalidateResources(modifiedIDs) + app.animationCache.InvalidateResources(modifiedIDs) +} + +func (app *Application) modReset() { + app.cmdStack = new(cmd.Stack) +} + +// nolint: lll +func (app *Application) initView() { + textViewer := media.NewTextViewerService(app.textLineCache, app.textPageCache, app.mod) + textSetter := media.NewTextSetterService(app.cp) + audioViewer := media.NewAudioViewerService(app.movieCache, app.mod) + audioSetter := media.NewAudioSetterService() + movieViewer := media.NewMovieViewerService(app.movieCache, app.mod) + movieSetter := media.NewMovieSetterService(app.cp) + soundEffectViewer := media.NewSoundViewerService(app.soundEffectCache, app.mod) + soundEffectSetter := media.NewSoundSetterService() + soundEffectService := undoable.NewSoundEffectService(edit.NewSoundEffectService(soundEffectViewer, soundEffectSetter), app) + augmentedTextService := undoable.NewAugmentedTextService(edit.NewAugmentedTextService(textViewer, textSetter, audioViewer, audioSetter), app) + movieService := undoable.NewMovieService(edit.NewMovieService(app.cp, movieViewer, movieSetter), app) + + app.projectService = edit.NewProjectService(&app.txnBuilder, app.mod) + app.gameStateService = edit.NewGameStateService(&app.txnBuilder) + + app.projectView = project.NewView(app.projectService, &app.modalState, app.GuiScale, &app.txnBuilder) + app.archiveView = archives.NewArchiveView(&app.txnBuilder, app.gameStateService, app.mod, app.textLineCache, app.cp, &app.modalState, app.GuiScale, app) + app.levelControlView = levels.NewControlView(app.levels, app.levelSelection, app.levelEditorService, app.GuiScale, app.textLineCache, app.textureCache, &app.txnBuilder) + app.levelTilesView = levels.NewTilesView(app.levelEditorService, app.GuiScale, app.textLineCache, app.textureCache, &app.txnBuilder) + app.levelObjectsView = levels.NewObjectsView(app.gameObjectsService, app.levelEditorService, app.levelSelection, app.gameStateService, app.GuiScale, app.textLineCache, app.textureCache, &app.txnBuilder, app.gl) + app.messagesView = messages.NewMessagesView(app.mod, app.messagesCache, app.cp, app.movieCache, app.textureCache, &app.modalState, app.clipboard, app.GuiScale, app) + app.textsView = texts.NewTextsView(augmentedTextService, &app.modalState, app.clipboard, app.GuiScale) + app.bitmapsView = bitmaps.NewBitmapsView(app.mod, app.textureCache, app.paletteCache, &app.modalState, app.clipboard, app.GuiScale, app) + app.texturesView = textures.NewTexturesView(app.mod, app.textLineCache, app.cp, app.textureCache, app.paletteCache, &app.modalState, app.clipboard, app.GuiScale, app) + app.animationsView = animations.NewAnimationsView(app.mod, app.textureCache, app.paletteCache, app.animationCache, &app.modalState, app.GuiScale, app) + app.moviesView = movies.NewMoviesView(app.mod, app.frameCache, movieService, &app.modalState, app.GuiScale, app) + app.soundEffectsView = sounds.NewSoundEffectsView(soundEffectService, &app.modalState, app.GuiScale) + app.objectsView = objects.NewView(app.mod, app.textLineCache, app.cp, app.textureCache, app.paletteCache, &app.modalState, app.clipboard, app.GuiScale, app) + app.aboutView = about.NewView(app.clipboard, app.GuiScale, app.Version) + app.licensesView = about.NewLicensesView(app.GuiScale) +} + +// Queue requests to perform the given command. +func (app *Application) Queue(command cmd.Command) { + err := app.projectService.ModifyModWith(func(modder world.Modder) error { + return app.cmdStack.Perform(command, modder) + }) + if err != nil { + app.onFailure("command", "", err) + } +} + +func (app *Application) onFailure(source string, details string, err error) { + app.failurePending = true + app.failureMessage = fmt.Sprintf("Source: %v\nDetails: %v\nError: %v", source, details, err) +} + +func (app *Application) renderMainMenu() { + windowEntry := func(name string, shortcut string, isOpen *bool) { + if imgui.MenuItemV(name, shortcut, *isOpen, true) { + *isOpen = !*isOpen + } + } + + if imgui.BeginMainMenuBar() { + if imgui.BeginMenu("File") { + if imgui.MenuItemV("New Project", "Ctrl+N", false, true) { + app.newProject() + } + if imgui.MenuItemV("Load Project", "", false, true) { + app.loadProject() + } + if imgui.MenuItemV("Save Project", "", false, true) { + app.saveProject() + } + imgui.Separator() + if imgui.MenuItemV("Save Mod", "Ctrl+S", false, true) { + app.projectView.StartSavingMod() + } + windowEntry("Project", "F1", app.projectView.WindowOpen()) + imgui.Separator() + if imgui.MenuItem("Exit") { + app.window.SetCloseRequest(true) + } + imgui.EndMenu() + } + if imgui.BeginMenu("Edit") { + if imgui.MenuItemV("Undo", "Ctrl+Z", false, app.cmdStack.CanUndo()) { + app.tryUndo() + } + if imgui.MenuItemV("Redo", "Ctrl+Y / Ctrl+Shift+Z", false, app.cmdStack.CanRedo()) { + app.tryRedo() + } + imgui.EndMenu() + } + if imgui.BeginMenu("Window") { + windowEntry("Archive", "", app.archiveView.WindowOpen()) + windowEntry("Level Control", "F2", app.levelControlView.WindowOpen()) + windowEntry("Level Tiles", "F3", app.levelTilesView.WindowOpen()) + windowEntry("Level Objects", "F4", app.levelObjectsView.WindowOpen()) + windowEntry("Messages", "F5", app.messagesView.WindowOpen()) + windowEntry("Texts", "", app.textsView.WindowOpen()) + windowEntry("Bitmaps", "", app.bitmapsView.WindowOpen()) + windowEntry("Textures", "", app.texturesView.WindowOpen()) + windowEntry("Animations", "", app.animationsView.WindowOpen()) + windowEntry("Movies", "", app.moviesView.WindowOpen()) + windowEntry("Sound Effects", "", app.soundEffectsView.WindowOpen()) + windowEntry("Game Objects", "", app.objectsView.WindowOpen()) + imgui.EndMenu() + } + if imgui.BeginMenu("Help") { + if imgui.MenuItem("About...") { + app.aboutView.Show() + } + if imgui.MenuItem("Licenses") { + app.licensesView.Show() + } + imgui.EndMenu() + } + imgui.EndMainMenuBar() + } +} + +func (app *Application) handleFailure() { + if app.failurePending { + imgui.OpenPopup("Failure Message") + app.failurePending = false + } + if imgui.BeginPopupModal("Failure Message") { + imgui.Text(`Something went wrong. This is bad and I am sorry. + +You have the option to "Ignore" this and hope for the best. +This action also clears the undo/redo buffer. + +Or you can simply "Exit" the application and then restart it. +This action loses any pending changes. + +Perhaps you can make something with the details of the error below. +If you can reproduce this, please make a screenshot and +report it with details on how to reproduce it on the +http://www.systemshock.org forums. Thank you! +`) + imgui.Text("Version: " + app.Version) + imgui.Separator() + imgui.Text(app.failureMessage) + imgui.Separator() + if imgui.Button("Ignore") { + app.failureMessage = "" + app.cmdStack = new(cmd.Stack) + imgui.CloseCurrentPopup() + } + imgui.SameLine() + if imgui.Button("Exit") { + app.window.SetCloseRequest(true) + } + imgui.EndPopup() + } +} + +const settingsFileExtension = "hacked-project" + +func projectFileTypes() []external.TypeInfo { + return []external.TypeInfo{ + { + Title: "HackEd Project File (*." + settingsFileExtension + ")", + Extensions: []string{settingsFileExtension}, + }, + } +} + +func (app *Application) newProject() { + app.saveStoredProject() + app.restoreProjectState(projectState{}, "") +} + +func (app *Application) loadProject() { + app.saveStoredProject() + + external.LoadFile(&app.modalState, projectFileTypes(), func(filename string) error { + state, err := projectStateFromFile(filename) + if err != nil { + return err + } + app.restoreProjectState(state, filename) + return nil + }) +} + +func (app *Application) saveStoredProject() { + currentFilename := app.projectService.CurrentStateFilename() + if len(currentFilename) > 0 { + currentState := app.currentProjectState() + _ = currentState.SaveTo(currentFilename) + } +} + +func (app *Application) saveProject() { + currentState := app.currentProjectState() + currentFilename := app.projectService.CurrentStateFilename() + if (len(currentFilename) == 0) || (currentState.SaveTo(currentFilename) != nil) { + external.SaveFile(&app.modalState, projectFileTypes(), func(filename string) error { + completeFilename := filename + dotExtension := "." + settingsFileExtension + if !strings.HasSuffix(completeFilename, dotExtension) { + completeFilename += dotExtension + } + err := currentState.SaveTo(completeFilename) + if err != nil { + return err + } + app.projectService.SetCurrentStateFilename(completeFilename) + app.window.SetTitleSuffix(completeFilename) + return nil + }) + } +} + +func (app *Application) currentProjectState() projectState { + projectSettings := app.projectService.CurrentSettings() + gameStateSettings := app.gameStateService.CurrentSettings() + + windowOpenByName := app.windowOpenByName() + var openWindows []string + for key, open := range windowOpenByName { + if *open { + openWindows = append(openWindows, key) + } + } + activeLevelIndex := app.levelSelection.CurrentLevelID() + return projectState{ + ProjectSettings: &projectSettings, + GameStateSettings: &gameStateSettings, + OpenWindows: openWindows, + ActiveLevelIndex: &activeLevelIndex, + } +} + +func (app *Application) saveWorkspace() { + currentProjectState := app.currentProjectState() + currentProjectStateFilename := app.projectService.CurrentStateFilename() + if len(currentProjectStateFilename) > 0 { + err := currentProjectState.SaveTo(currentProjectStateFilename) + if err != nil { + currentProjectStateFilename = "" + } else { + currentProjectState = projectState{} + } + } + + lastProjectState := lastProjectState{ + projectState: currentProjectState, + LastProject: currentProjectStateFilename, + } + _ = lastProjectState.SaveTo(app.lastProjectStateConfigFilename()) +} + +func (app *Application) restoreWorkspace() { + lastProjectState := lastProjectStateFromFile(app.lastProjectStateConfigFilename()) + + projectStateToRestore := lastProjectState.projectState + + projectStateFilename := "" + if len(lastProjectState.LastProject) > 0 { + state, err := projectStateFromFile(lastProjectState.LastProject) + if err == nil { + projectStateToRestore = state + projectStateFilename = lastProjectState.LastProject + } + } + + app.restoreProjectState(projectStateToRestore, projectStateFilename) +} + +func (app *Application) restoreProjectState(state projectState, filename string) { + var projectSettings edit.ProjectSettings + if state.ProjectSettings != nil { + projectSettings = *state.ProjectSettings + } + app.projectService.RestoreProject(projectSettings, filename) + var gameStateSettings edit.GameStateSettings + if state.GameStateSettings != nil { + gameStateSettings = *state.GameStateSettings + } + app.gameStateService.RestoreSettings(gameStateSettings) + + windowOpenByName := app.windowOpenByName() + for _, open := range windowOpenByName { + *open = false + } + for _, key := range state.OpenWindows { + open := windowOpenByName[key] + if open != nil { + *open = true + } + } + if len(state.OpenWindows) == 0 { + *app.projectView.WindowOpen() = true + } + + activeLevel := world.StartingLevel + if (state.ActiveLevelIndex != nil) && app.levels.IsLevelAvailable(*state.ActiveLevelIndex) { + activeLevel = *state.ActiveLevelIndex + } + app.levelSelection.SetCurrentLevelID(activeLevel) + + app.window.SetTitleSuffix(filename) +} + +func (app *Application) updateAutoSave() { + status := app.projectService.SaveStatus() + app.window.SetProjectModified(status.FilesModified > 0) + if status.SavePending && (status.SaveIn == 0) { + status.ConfirmPendingSave() + app.projectView.StartSavingMod() + } +} + +func (app *Application) windowStateConfigFilename() string { + return filepath.Join(app.ConfigDir, "WindowState.json") +} + +func (app *Application) lastProjectStateConfigFilename() string { + return filepath.Join(app.ConfigDir, "LastProjectState.json") +} + +func (app *Application) windowOpenByName() map[string]*bool { + return map[string]*bool{ + "project": app.projectView.WindowOpen(), + "archive": app.archiveView.WindowOpen(), + "levelControl": app.levelControlView.WindowOpen(), + "levelTiles": app.levelTilesView.WindowOpen(), + "levelObjects": app.levelObjectsView.WindowOpen(), + "messages": app.messagesView.WindowOpen(), + "texts": app.textsView.WindowOpen(), + "bitmaps": app.bitmapsView.WindowOpen(), + "textures": app.texturesView.WindowOpen(), + "animations": app.animationsView.WindowOpen(), + "movies": app.moviesView.WindowOpen(), + "soundEffects": app.soundEffectsView.WindowOpen(), + "gameObjects": app.objectsView.WindowOpen(), + } +} diff --git a/modding/hacked/editor/ClipboardAdapter.go b/modding/hacked/editor/ClipboardAdapter.go new file mode 100644 index 0000000..cb6b6e8 --- /dev/null +++ b/modding/hacked/editor/ClipboardAdapter.go @@ -0,0 +1,15 @@ +package editor + +import "github.com/inkyblackness/hacked/ui/opengl" + +type clipboardAdapter struct { + window opengl.Window +} + +func (adapter clipboardAdapter) String() (string, error) { + return adapter.window.ClipboardString() +} + +func (adapter clipboardAdapter) SetString(value string) { + adapter.window.SetClipboardString(value) +} diff --git a/modding/hacked/editor/about/LicensesView.go b/modding/hacked/editor/about/LicensesView.go new file mode 100644 index 0000000..cf4272c --- /dev/null +++ b/modding/hacked/editor/about/LicensesView.go @@ -0,0 +1,66 @@ +package about + +import ( + "github.com/inkyblackness/imgui-go/v3" +) + +// LicensesView handles the about display. +type LicensesView struct { + guiScale float32 + + model viewModel +} + +// NewLicensesView creates a new instance for the about display. +func NewLicensesView(guiScale float32) *LicensesView { + return &LicensesView{ + guiScale: guiScale, + + model: freshViewModel(), + } +} + +// Show requests to show the licenses view. +func (view *LicensesView) Show() { + view.model.windowOpen = true +} + +// Render requests to render the view. +func (view *LicensesView) Render() { + if view.model.windowOpen { + imgui.OpenPopup("Licenses") + view.model.windowOpen = false + imgui.SetNextWindowSize(imgui.Vec2{X: 640 * view.guiScale, Y: 480 * view.guiScale}) + } + if imgui.BeginPopupModalV("Licenses", nil, imgui.WindowFlagsHorizontalScrollbar|imgui.WindowFlagsNoSavedSettings) { + view.renderContent() + imgui.EndPopup() + } +} + +func (view *LicensesView) renderContent() { + if imgui.TreeNodeV("System Shock", imgui.TreeNodeFlagsDefaultOpen) { + imgui.Text("System Shock by Night Dive Studios, LLC.\nOriginally by Looking Glass Technologies") + imgui.TreePop() + } + add := func(title, license string) { + if imgui.TreeNode(title) { + imgui.Text(license) + imgui.TreePop() + } + } + + add("InkyBlackness - HackEd", inkyblacknessHackedLicense) + add("InkyBlackness - imgui", inkyblacknessImGuiLicense) + add("go-gl/glfw - Go bindings for GLFW 3", goglGlfwLicense) + add("go-gl/gl", goglOpenGLLicense) + add("go-gl/mathgl", goglMathGLLicense) + add("Dear ImGui", dearImGuiLicense) + add("GLFW", glfwLicense) + add("MinGW-w64", mingwLicense) + + imgui.Separator() + if imgui.Button("OK") { + imgui.CloseCurrentPopup() + } +} diff --git a/modding/hacked/editor/about/View.go b/modding/hacked/editor/about/View.go new file mode 100644 index 0000000..d2d790f --- /dev/null +++ b/modding/hacked/editor/about/View.go @@ -0,0 +1,70 @@ +package about + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/external" +) + +// View handles the about display. +type View struct { + version string + guiScale float32 + clipboard external.Clipboard + + model viewModel +} + +// NewView creates a new instance for the about display. +func NewView(clipboard external.Clipboard, guiScale float32, version string) *View { + return &View{ + version: version, + guiScale: guiScale, + clipboard: clipboard, + + model: freshViewModel(), + } +} + +// Show requests to show the about view. +func (view *View) Show() { + view.model.windowOpen = true +} + +// Render requests to render the view. +func (view *View) Render() { + if view.model.windowOpen { + imgui.OpenPopup("About") + view.model.windowOpen = false + } + if imgui.BeginPopupModalV("About", nil, imgui.WindowFlagsAlwaysAutoResize|imgui.WindowFlagsNoMove|imgui.WindowFlagsHorizontalScrollbar) { + view.renderContent() + imgui.EndPopup() + } +} + +func (view *View) renderContent() { + projectURL := "https://inkyblackness.github.io" + communityURL := "https://www.systemshock.org" + userguideURL := "https://github.com/inkyblackness/hacked/wiki" + urlLine := func(title, url string) { + imgui.Text(title + ": " + url) + imgui.SameLine() + if imgui.Button("-> Clip##" + title) { + view.clipboard.SetString(url) + } + } + + imgui.Text("InkyBlackness - HackEd - " + view.version) + imgui.Separator() + urlLine("User guide", userguideURL) + urlLine("Community", communityURL) + urlLine("Project", projectURL) + imgui.Separator() + imgui.Text("Thanks to: 3RDPlayer: testing, ToxicFrog: name idea.") + imgui.Text("Written in Go. Because I can.") + imgui.Separator() + if imgui.Button("OK") { + imgui.CloseCurrentPopup() + } +} diff --git a/modding/hacked/editor/about/ViewModel.go b/modding/hacked/editor/about/ViewModel.go new file mode 100644 index 0000000..b80849e --- /dev/null +++ b/modding/hacked/editor/about/ViewModel.go @@ -0,0 +1,9 @@ +package about + +type viewModel struct { + windowOpen bool +} + +func freshViewModel() viewModel { + return viewModel{} +} diff --git a/modding/hacked/editor/about/licenses.go b/modding/hacked/editor/about/licenses.go new file mode 100644 index 0000000..e502920 --- /dev/null +++ b/modding/hacked/editor/about/licenses.go @@ -0,0 +1,227 @@ +package about + +var inkyblacknessHackedLicense = `New BSD License + +© 2018, Christian Haas +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of "InkyBlackness" nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE LISTED COPYRIGHT HOLDERS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +` + +var inkyblacknessImGuiLicense = `New BSD License + +© 2018, Christian Haas +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of "InkyBlackness" nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE LISTED COPYRIGHT HOLDERS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +` + +var dearImGuiLicense = `The MIT License (MIT) + +Copyright (c) 2014-2018 Omar Cornut + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +` + +var mingwLicense = `With exception of certain parts that are prominently marked as being +in the Public Domain, BSD, or LGPL this Software is provided under the +Zope Public License (ZPL) Version 2.1. + +Copyright (c) 2009 - 2013 by the mingw-w64 project + +See the AUTHORS file for the list of contributors to the mingw-w64 project. + +This license has been certified as open source. It has also been designated +as GPL compatible by the Free Software Foundation (FSF). + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + 1. Redistributions in source code must retain the accompanying copyright + notice, this list of conditions, and the following disclaimer. + 2. Redistributions in binary form must reproduce the accompanying + copyright notice, this list of conditions, and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + 3. Names of the copyright holders must not be used to endorse or promote + products derived from this software without prior written permission + from the copyright holders. + 4. The right to distribute this software or to use it for any purpose does + not give you the right to use Servicemarks (sm) or Trademarks (tm) of + the copyright holders. Use of them is covered by separate agreement + with the copyright holders. + 5. If any files are modified, you must cause the modified files to carry + prominent notices stating that you changed the files and the date of + any change. + +Disclaimer + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ''AS IS'' AND ANY EXPRESSED +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO +EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +` + +var goglGlfwLicense = `Copyright (c) 2012 The glfw3-go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +` + +var goglOpenGLLicense = `The MIT License (MIT) + +Copyright (c) 2014 Eric Woroshow + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +` + +var goglMathGLLicense = `Copyright ©2013 The go-gl Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the gonum project nor the names of its authors and + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +` + +var glfwLicense = `Copyright (c) 2002-2006 Marcus Geelnard +Copyright (c) 2006-2016 Camilla Löwy + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would + be appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not + be misrepresented as being the original software. + +3. This notice may not be removed or altered from any source + distribution. +` diff --git a/modding/hacked/editor/animations/SetAnimationCommand.go b/modding/hacked/editor/animations/SetAnimationCommand.go new file mode 100644 index 0000000..9f0b366 --- /dev/null +++ b/modding/hacked/editor/animations/SetAnimationCommand.go @@ -0,0 +1,41 @@ +package animations + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +type setAnimationCommand struct { + model *viewModel + + animationKey resource.Key + framesID resource.ID + + oldAnimation []byte + newAnimation []byte + + oldFrames [][]byte + newFrames [][]byte +} + +func (command setAnimationCommand) Do(modder world.Modder) error { + return command.perform(modder, command.newAnimation, command.newFrames) +} + +func (command setAnimationCommand) Undo(modder world.Modder) error { + return command.perform(modder, command.oldAnimation, command.oldFrames) +} + +func (command setAnimationCommand) perform(modder world.Modder, animData []byte, frames [][]byte) error { + if len(frames) == 0 { + modder.DelResource(command.animationKey.Lang, command.animationKey.ID.Plus(command.animationKey.Index)) + modder.DelResource(command.animationKey.Lang, command.framesID) + } else { + modder.SetResourceBlock(command.animationKey.Lang, command.animationKey.ID.Plus(command.animationKey.Index), 0, animData) + modder.SetResourceBlocks(command.animationKey.Lang, command.framesID, frames) + } + + command.model.restoreFocus = true + command.model.currentKey = command.animationKey + return nil +} diff --git a/modding/hacked/editor/animations/View.go b/modding/hacked/editor/animations/View.go new file mode 100644 index 0000000..bb09b28 --- /dev/null +++ b/modding/hacked/editor/animations/View.go @@ -0,0 +1,338 @@ +package animations + +import ( + "bytes" + "encoding/binary" + "fmt" + "image" + "image/gif" + "os" + "path/filepath" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/external" + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial/rle" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/gui" +) + +type animationInfo struct { + title string +} + +var knownAnimationTypes = map[resource.ID]animationInfo{ + ids.VideoMailAnimationsStart: {title: "Video Mail Parts"}, +} + +var knownAnimationTypesOrder = []resource.ID{ + ids.VideoMailAnimationsStart, +} + +// View provides edit controls for animations. +type View struct { + mod *world.Mod + imageCache *graphics.TextureCache + paletteCache *graphics.PaletteCache + animationCache *bitmap.AnimationCache + + modalStateMachine gui.ModalStateMachine + guiScale float32 + commander cmd.Commander + + model viewModel +} + +// NewAnimationsView returns a new instance. +func NewAnimationsView(mod *world.Mod, imageCache *graphics.TextureCache, paletteCache *graphics.PaletteCache, + animationCache *bitmap.AnimationCache, + modalStateMachine gui.ModalStateMachine, guiScale float32, commander cmd.Commander) *View { + view := &View{ + mod: mod, + imageCache: imageCache, + paletteCache: paletteCache, + animationCache: animationCache, + + modalStateMachine: modalStateMachine, + guiScale: guiScale, + commander: commander, + + model: freshViewModel(), + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *View) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render renders the view. +func (view *View) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + imgui.SetNextWindowSizeV(imgui.Vec2{X: 800 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV("Animations", view.WindowOpen(), imgui.WindowFlagsNoCollapse|imgui.WindowFlagsHorizontalScrollbar) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + if imgui.BeginChildV("Properties", imgui.Vec2{X: 350 * view.guiScale, Y: 0}, false, 0) { + imgui.PushItemWidth(-150 * view.guiScale) + if imgui.BeginCombo("Animation Type", knownAnimationTypes[view.model.currentKey.ID].title) { + for _, id := range knownAnimationTypesOrder { + if imgui.SelectableV(knownAnimationTypes[id].title, id == view.model.currentKey.ID, 0, imgui.Vec2{}) { + view.model.currentKey.ID = id + view.model.currentKey.Index = 0 + view.model.currentFrame = 0 + } + } + imgui.EndCombo() + } + + info, _ := ids.Info(view.model.currentKey.ID) + + if gui.StepSliderInt("Index", &view.model.currentKey.Index, 0, info.MaxCount-1) { + view.model.currentFrame = 0 + } + + imgui.Separator() + + view.renderProperties() + + imgui.PopItemWidth() + } + imgui.EndChild() + imgui.SameLine() + + if imgui.BeginChildV("Frames", imgui.Vec2{X: -1, Y: 0}, false, 0) { + anim, hasAnim, _ := view.currentAnimation() + + if hasAnim { + frameKey := resource.KeyOf(anim.ResourceID, resource.LangAny, view.model.currentFrame) + if view.cacheFrame(frameKey) { + render.TextureImage("Frame", view.imageCache, frameKey, + imgui.Vec2{X: float32(anim.Width) * view.guiScale, Y: float32(anim.Height) * view.guiScale}) + if imgui.Button("Export") { + view.requestExport() + } + } + imgui.SameLine() + if imgui.Button("Import") { + view.requestImport() + } + } + } + imgui.EndChild() +} + +func (view *View) cacheFrame(key resource.Key) bool { + var err error + var lastKey *resource.Key + for index := 0; (index <= key.Index) && (err == nil); index++ { + nextKey := resource.KeyOf(key.ID, key.Lang, index) + _, err = view.imageCache.TextureReferenced(nextKey, lastKey) + lastKey = &nextKey + } + return err == nil +} + +func (view *View) renderProperties() { + anim, hasAnim, _ := view.currentAnimation() + widthString := "" + heightString := "" + lastFrame := 0 + + if hasAnim { + widthString = fmt.Sprintf("%d", anim.Width) + heightString = fmt.Sprintf("%d", anim.Height) + for _, entry := range anim.Entries { + lastFrame = int(entry.LastFrame) + } + } + + imgui.LabelText("Width", widthString) + imgui.LabelText("Height", heightString) + + gui.StepSliderInt("Frame Index", &view.model.currentFrame, 0, lastFrame) +} + +func (view *View) currentAnimation() (bitmap.Animation, bool, bool) { + key := resource.KeyOf(view.model.currentKey.ID.Plus(view.model.currentKey.Index), resource.LangAny, 0) + anim, err := view.animationCache.Animation(key) + if err != nil { + return anim, false, true + } + readOnly := len(view.mod.ModifiedBlocks(resource.LangAny, key.ID)) == 0 + return anim, true, readOnly +} + +func (view *View) requestImport() { + info := "File must be an animated GIF file.\nIdeally, it matches the game palette 1:1,\nothers are mapped closest fitting." + types := []external.TypeInfo{{Title: "Animation files (*.gif)", Extensions: []string{"gif"}}} + var fileHandler func(string) + + fileHandler = func(filename string) { + reader, err := os.Open(filename) + if err != nil { + external.Import(view.modalStateMachine, "Could not open file.\n"+info, types, fileHandler, true) + return + } + defer func() { _ = reader.Close() }() + data, err := gif.DecodeAll(reader) + if err != nil { + external.Import(view.modalStateMachine, "File not recognized as GIF.\n"+info, types, fileHandler, true) + return + } + + palette, err := view.paletteCache.Palette(0) + if err != nil { + external.Import(view.modalStateMachine, "Can not import image without having a palette loaded.\n"+info, types, fileHandler, true) + return + } + anim := bitmap.Animation{ + Width: int16(data.Config.Width), + Height: int16(data.Config.Height), + ResourceID: view.model.currentKey.ID.Plus(view.model.currentKey.Index).Plus(-12), + IntroFlag: 0, + } + var frames [][]byte + rawPalette := palette.Palette() + + highestBitShift := func(value int16) (result byte) { + if value != 0 { + for (value >> result) != 1 { + result++ + } + } + return + } + + var prevFrame []byte + for index, img := range data.Image { + if (img.Bounds().Max.X == data.Config.Width) && (img.Bounds().Max.Y == data.Config.Height) { + bitmapper := bitmap.NewBitmapper(&rawPalette) + bmp := bitmapper.Map(img) + entry := bitmap.AnimationEntry{ + FirstFrame: byte(index), + LastFrame: byte(index), + FrameTime: int16(data.Delay[index] * 10), + } + anim.Entries = append(anim.Entries, entry) + bmp.Header.Type = bitmap.TypeCompressed8Bit + bmp.Header.WidthFactor = highestBitShift(bmp.Header.Width) + bmp.Header.HeightFactor = highestBitShift(bmp.Header.Height) + bmp.Header.Area = [4]int16{0, 0, anim.Width, anim.Height} + bmp.Header.Stride = uint16(bmp.Header.Width) + + buf := bytes.NewBuffer(nil) + _ = binary.Write(buf, binary.LittleEndian, &bmp.Header) + _ = rle.Compress(buf, bmp.Pixels, prevFrame) + prevFrame = bmp.Pixels + frames = append(frames, buf.Bytes()) + } + } + + view.requestSetAnimation(anim, frames) + } + + external.Import(view.modalStateMachine, info, types, fileHandler, false) +} + +func (view *View) requestExport() { + anim, hasAnim, _ := view.currentAnimation() + if hasAnim { + filename := fmt.Sprintf("Anim%04X.gif", int(view.model.currentKey.ID.Plus(view.model.currentKey.Index))) + view.exportTo(filename, anim) + } +} + +func (view *View) exportTo(filename string, anim bitmap.Animation) { + info := "File to be written: " + filename + var exportTo func(string) + + exportTo = func(dirname string) { + writer, err := os.Create(filepath.Join(dirname, filename)) + if err != nil { + external.Export(view.modalStateMachine, "Could not create file.\n"+info, exportTo, true) + return + } + defer func() { _ = writer.Close() }() + + palTex, err := view.paletteCache.Palette(0) + if err != nil { + external.Export(view.modalStateMachine, "Could not create file. No palette loaded.\n"+info, exportTo, true) + return + } + + colorPalette := palTex.Palette().ColorPalette(false) + data := gif.GIF{ + Config: image.Config{ + Width: int(anim.Width), + Height: int(anim.Height), + ColorModel: colorPalette, + }, + LoopCount: -1, + } + + imageRect := image.Rect(0, 0, data.Config.Width, data.Config.Height) + frameIndex := 0 + for _, entry := range anim.Entries { + for frameIndex <= int(entry.LastFrame) { + frameKey := resource.KeyOf(anim.ResourceID, resource.LangAny, frameIndex) + if !view.cacheFrame(frameKey) { + external.Export(view.modalStateMachine, "Failed to cache frame.\n"+info, exportTo, true) + return + } + frameTex, _ := view.imageCache.Texture(frameKey) + frameImg := image.NewPaletted(imageRect, colorPalette) + frameImg.Pix = frameTex.PixelData() + data.Image = append(data.Image, frameImg) + data.Delay = append(data.Delay, int(entry.FrameTime)/10) + frameIndex++ + } + } + + err = gif.EncodeAll(writer, &data) + if err != nil { + external.Export(view.modalStateMachine, info, exportTo, true) + return + } + } + + external.Export(view.modalStateMachine, info, exportTo, false) +} + +func (view *View) requestSetAnimation(newAnim bitmap.Animation, newFrames [][]byte) { + encodeAnim := func(anim bitmap.Animation) []byte { + buf := bytes.NewBuffer(nil) + _ = bitmap.WriteAnimation(buf, anim) + return buf.Bytes() + } + command := setAnimationCommand{ + model: &view.model, + animationKey: view.model.currentKey, + newAnimation: encodeAnim(newAnim), + newFrames: newFrames, + framesID: newAnim.ResourceID, + } + oldAnim, oldExisting, _ := view.currentAnimation() + if oldExisting { + command.oldAnimation = encodeAnim(oldAnim) + } + command.oldFrames = view.mod.ModifiedBlocks(view.model.currentKey.Lang, newAnim.ResourceID) + view.commander.Queue(command) +} diff --git a/modding/hacked/editor/animations/ViewModel.go b/modding/hacked/editor/animations/ViewModel.go new file mode 100644 index 0000000..aaacf7b --- /dev/null +++ b/modding/hacked/editor/animations/ViewModel.go @@ -0,0 +1,20 @@ +package animations + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +type viewModel struct { + windowOpen bool + restoreFocus bool + + currentKey resource.Key + currentFrame int +} + +func freshViewModel() viewModel { + return viewModel{ + currentKey: resource.KeyOf(ids.VideoMailAnimationsStart, resource.LangAny, 0), + } +} diff --git a/modding/hacked/editor/archives/EditBooleanVariableDialog.go b/modding/hacked/editor/archives/EditBooleanVariableDialog.go new file mode 100644 index 0000000..14a71e0 --- /dev/null +++ b/modding/hacked/editor/archives/EditBooleanVariableDialog.go @@ -0,0 +1,125 @@ +package archives + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/values" + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ui/gui" +) + +type editBooleanVariableDialog struct { + machine gui.ModalStateMachine + view *View + + service *edit.GameStateService + index int + varOverride bool + info archive.GameVariableInfo + + opened bool +} + +func (dialog *editBooleanVariableDialog) Render() { + if !dialog.opened { + dialog.opened = true + dialog.varOverride = dialog.service.BooleanVariableOverride(dialog.index) + dialog.info = dialog.service.BooleanVariable(dialog.index) + imgui.OpenPopup("Edit boolean variable") + } + + imgui.SetNextWindowSize(imgui.Vec2{X: 600, Y: 0}.Times(dialog.view.guiScale)) + if imgui.BeginPopupModalV("Edit boolean variable", nil, + imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) { + dialog.renderControls() + imgui.Separator() + if imgui.Button("OK") { + dialog.applyChanges() + dialog.close() + } + imgui.SameLine() + if imgui.Button("Cancel") { + dialog.close() + } + imgui.EndPopup() + } else { + dialog.machine.SetState(nil) + } +} + +func (dialog *editBooleanVariableDialog) applyChanges() { + if dialog.varOverride { + _ = dialog.service.SetBooleanVariable(dialog.index, dialog.info) + } else { + _ = dialog.service.DefaultBooleanVariable(dialog.index) + } +} + +func (dialog *editBooleanVariableDialog) close() { + dialog.machine.SetState(nil) + imgui.CloseCurrentPopup() +} + +func (dialog *editBooleanVariableDialog) renderControls() { + imgui.PushItemWidth(-150 * dialog.view.guiScale) + values.RenderUnifiedCheckboxCombo(false, "Override", values.UnifierFor(dialog.varOverride), func(newValue bool) { + dialog.varOverride = newValue + }) + + textFlags := imgui.InputTextFlagsNoUndoRedo + if !dialog.varOverride { + textFlags |= imgui.InputTextFlagsReadOnly + } + imgui.InputTextV("Name", &dialog.info.Name, textFlags, nil) + imgui.InputTextMultilineV("Description", &dialog.info.Description, imgui.Vec2{X: 0, Y: 100 * dialog.view.guiScale}, + textFlags, nil) + + imgui.Separator() + defaultValues := map[int]string{0: "False", 1: "True"} + valueLabels := map[bool]string{false: "Default", true: "Special"} + hasSpecialValues := dialog.info.ValueNames != nil + if dialog.varOverride { + if imgui.BeginCombo("Values", valueLabels[hasSpecialValues]) { + if imgui.SelectableV(valueLabels[false], !hasSpecialValues, 0, imgui.Vec2{}) { + dialog.info.ValueNames = nil + } + if imgui.SelectableV(valueLabels[true], hasSpecialValues, 0, imgui.Vec2{}) { + dialog.info.ValueNames = map[int16]string{0: defaultValues[0], 1: defaultValues[1]} + } + imgui.EndCombo() + } + } else { + imgui.LabelText("Values", valueLabels[hasSpecialValues]) + } + hasSpecialValues = dialog.info.ValueNames != nil + if hasSpecialValues { + for i := int16(0); i < 2; i++ { + text := dialog.info.ValueNames[i] + if imgui.InputTextV(defaultValues[int(i)], &text, textFlags, nil) { + dialog.info.ValueNames[i] = text + } + } + } else { + imgui.LabelText(defaultValues[0], defaultValues[0]) + imgui.LabelText(defaultValues[1], defaultValues[1]) + } + + initUnifier := values.UnifierFor(dialog.info.ResetValueInt()) + values.RenderUnifiedCombo(dialog.info.Hardcoded || !dialog.varOverride, "Reset Value", initUnifier, + func(u values.Unifier) int { return int(u.Unified().(int16)) }, + func(value int) string { + name, found := dialog.info.ValueNames[int16(value)] + if found { + return name + } + return defaultValues[value] + }, + 2, + func(newValue int) { + value16 := int16(newValue) + dialog.info.InitValue = &value16 + }) + + imgui.PopItemWidth() +} diff --git a/modding/hacked/editor/archives/EditIntegerVariableDialog.go b/modding/hacked/editor/archives/EditIntegerVariableDialog.go new file mode 100644 index 0000000..d969893 --- /dev/null +++ b/modding/hacked/editor/archives/EditIntegerVariableDialog.go @@ -0,0 +1,214 @@ +package archives + +import ( + "fmt" + "math" + "sort" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/values" + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ui/gui" +) + +type editIntegerVariableDialog struct { + machine gui.ModalStateMachine + view *View + + service *edit.GameStateService + index int + varOverride bool + info archive.GameVariableInfo + + selectedEnum int + opened bool +} + +func (dialog *editIntegerVariableDialog) Render() { + if !dialog.opened { + dialog.opened = true + dialog.varOverride = dialog.service.IntegerVariableOverride(dialog.index) + dialog.info = dialog.service.IntegerVariable(dialog.index) + imgui.OpenPopup("Edit integer variable") + } + + imgui.SetNextWindowSize(imgui.Vec2{X: 600, Y: 0}.Times(dialog.view.guiScale)) + if imgui.BeginPopupModalV("Edit integer variable", nil, + imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) { + dialog.renderControls() + imgui.Separator() + if imgui.Button("OK") { + dialog.applyChanges() + dialog.close() + } + imgui.SameLine() + if imgui.Button("Cancel") { + dialog.close() + } + imgui.EndPopup() + } else { + dialog.machine.SetState(nil) + } +} + +func (dialog *editIntegerVariableDialog) applyChanges() { + if dialog.varOverride { + _ = dialog.service.SetIntegerVariable(dialog.index, dialog.info) + } else { + _ = dialog.service.DefaultIntegerVariable(dialog.index) + } +} + +func (dialog *editIntegerVariableDialog) close() { + dialog.machine.SetState(nil) + imgui.CloseCurrentPopup() +} + +func (dialog *editIntegerVariableDialog) renderControls() { + imgui.PushItemWidth(-150 * dialog.view.guiScale) + values.RenderUnifiedCheckboxCombo(false, "Override", values.UnifierFor(dialog.varOverride), func(newValue bool) { + dialog.varOverride = newValue + }) + + textFlags := imgui.InputTextFlagsNoUndoRedo + if !dialog.varOverride { + textFlags |= imgui.InputTextFlagsReadOnly + } + imgui.InputTextV("Name", &dialog.info.Name, textFlags, nil) + imgui.InputTextMultilineV("Description", &dialog.info.Description, imgui.Vec2{X: 0, Y: 100 * dialog.view.guiScale}, + textFlags, nil) + + imgui.Separator() + + valueLabels := map[bool]string{false: "Numeric", true: "Enumerated"} + isEnumerated := len(dialog.info.ValueNames) > 0 + if imgui.BeginCombo("Values", valueLabels[isEnumerated]) { + if imgui.SelectableV(valueLabels[false], !isEnumerated, 0, imgui.Vec2{}) { + dialog.info.ValueNames = nil + } + if imgui.SelectableV(valueLabels[true], isEnumerated, 0, imgui.Vec2{}) { + dialog.info.ValueNames = map[int16]string{0: "Zero"} + dialog.selectedEnum = 0 + } + imgui.EndCombo() + } + if len(dialog.info.ValueNames) > 0 { + gui.StepSliderInt("Index", &dialog.selectedEnum, math.MinInt16, math.MaxInt16) + if dialog.varOverride { + text := dialog.info.ValueNames[int16(dialog.selectedEnum)] + if imgui.InputTextV("Value", &text, textFlags, nil) { + if len(text) > 0 { + dialog.info.ValueNames[int16(dialog.selectedEnum)] = text + } else { + delete(dialog.info.ValueNames, int16(dialog.selectedEnum)) + if len(dialog.info.ValueNames) == 0 { + dialog.info.ValueNames[0] = "Zero" + } + } + } + } else { + imgui.LabelText("Value", dialog.info.ValueNames[int16(dialog.selectedEnum)]) + } + + resetValue := dialog.info.ResetValueInt() + enumEntries := toEnumEntries(dialog.info.ValueNames) + linearInitIndex := linearIndex(enumEntries, resetValue) + initUnifier := values.UnifierFor(linearInitIndex) + values.RenderUnifiedCombo(dialog.info.Hardcoded || !dialog.varOverride, "Reset Value", initUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { + if value < 0 { + return fmt.Sprintf("%d: (unmapped)", resetValue) + } + entry := enumEntries[value] + return fmt.Sprintf("%d: '%s'", entry.index, entry.value) + }, + len(enumEntries), + func(newValue int) { + dialog.info.InitValue = &enumEntries[newValue].index + }) + } else { + minValue := math.MinInt16 + maxValue := math.MaxInt16 + + if dialog.info.Limits != nil { + minValue = int(dialog.info.Limits.Minimum) + maxValue = int(dialog.info.Limits.Maximum) + } + if minValue > maxValue { + maxValue = minValue + } + if dialog.varOverride { + if gui.StepSliderInt("Minimum", &minValue, math.MinInt16, math.MaxInt16) { + if minValue > maxValue { + maxValue = minValue + } + } + if gui.StepSliderInt("Maximum", &maxValue, math.MinInt16, math.MaxInt16) { + if maxValue < minValue { + minValue = maxValue + } + } + if (minValue == math.MinInt16) && (maxValue == math.MaxInt16) { + dialog.info.Limits = nil + } else { + if dialog.info.Limits == nil { + dialog.info.Limits = &archive.GameVariableLimits{} + } + dialog.info.Limits.Minimum = int16(minValue) + dialog.info.Limits.Maximum = int16(maxValue) + } + } else { + imgui.LabelText("Minimum", fmt.Sprintf("%d", minValue)) + imgui.LabelText("Maximum", fmt.Sprintf("%d", maxValue)) + } + + resetValue := int(dialog.info.ResetValueInt()) + if dialog.info.Hardcoded || !dialog.varOverride { + imgui.LabelText("Reset Value", fmt.Sprintf("%d", resetValue)) + } else if gui.StepSliderInt("Reset Value", &resetValue, minValue, maxValue) { + resetValue16 := int16(resetValue) + dialog.info.InitValue = &resetValue16 + } + } + + imgui.PopItemWidth() +} + +type enumEntry struct { + index int16 + value string +} + +func toEnumEntries(mapped map[int16]string) []enumEntry { + minIndex := math.MaxInt16 + maxIndex := math.MinInt16 + for index := range mapped { + if int(index) < minIndex { + minIndex = int(index) + } + if int(index) > maxIndex { + maxIndex = int(index) + } + } + orderedEntries := make([]enumEntry, 0, len(mapped)) + for index, value := range mapped { + orderedEntries = append(orderedEntries, enumEntry{ + index: index, + value: value, + }) + } + sort.Slice(orderedEntries, func(a, b int) bool { return orderedEntries[a].index < orderedEntries[b].index }) + return orderedEntries +} + +func linearIndex(entries []enumEntry, index int16) int { + for linear, entry := range entries { + if entry.index == index { + return linear + } + } + return -1 +} diff --git a/modding/hacked/editor/archives/SetArchiveDataCommand.go b/modding/hacked/editor/archives/SetArchiveDataCommand.go new file mode 100644 index 0000000..e4d7872 --- /dev/null +++ b/modding/hacked/editor/archives/SetArchiveDataCommand.go @@ -0,0 +1,45 @@ +package archives + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +type setArchiveDataCommand struct { + model *viewModel + + selectedLevel int + + oldData map[resource.ID][]byte + newData map[resource.ID][]byte +} + +func (command setArchiveDataCommand) Do(modder world.Modder) error { + command.delResources(modder, command.oldData) + return command.perform(modder, command.newData) +} + +func (command setArchiveDataCommand) Undo(modder world.Modder) error { + command.delResources(modder, command.newData) + return command.perform(modder, command.oldData) +} + +func (command setArchiveDataCommand) delResources(modder world.Modder, data map[resource.ID][]byte) { + for id := range data { + modder.DelResource(resource.LangAny, id) + } +} + +// nolint: interfacer +func (command setArchiveDataCommand) perform(modder world.Modder, data map[resource.ID][]byte) error { + for id, blockData := range data { + if len(blockData) > 0 { + modder.SetResourceBlocks(resource.LangAny, id, [][]byte{blockData}) + } else { + modder.DelResource(resource.LangAny, id) + } + } + command.model.restoreFocus = true + command.model.selectedLevel = command.selectedLevel + return nil +} diff --git a/modding/hacked/editor/archives/View.go b/modding/hacked/editor/archives/View.go new file mode 100644 index 0000000..3b29a30 --- /dev/null +++ b/modding/hacked/editor/archives/View.go @@ -0,0 +1,934 @@ +package archives + +import ( + "fmt" + "math" + "strings" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/values" + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/content/archive/level/lvlids" + "github.com/inkyblackness/hacked/ss1/content/archive/level/lvlobj" + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/numbers" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/citadel" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/gui" +) + +const ( + hintUnknown = "???" +) + +var variableContextList = map[edit.VariableBaseContextIdentifier]struct { + title string + info string +}{ + edit.VariableContextCitadel: { + title: "Citadel Mission", + info: "Variables are listed based on use on Citadel.\n" + + "Useful for Citadel-based savegames and mods, such as 'New Game+' variants.", + }, + edit.VariableContextEngine: { + title: "Engine", + info: "Variables are listed based on the hardcoded engine behaviour.\n" + + "Useful for dedicated missions.", + }, +} + +// View provides edit controls for the archive. +type View struct { + registry cmd.Registry + gameStateService *edit.GameStateService + mod *world.Mod + textCache *text.Cache + cp text.Codepage + + modalStateMachine gui.ModalStateMachine + guiScale float32 + commander cmd.Commander + + model viewModel +} + +// NewArchiveView returns a new instance. +func NewArchiveView(registry cmd.Registry, + gameStateService *edit.GameStateService, mod *world.Mod, + textCache *text.Cache, cp text.Codepage, + modalStateMachine gui.ModalStateMachine, guiScale float32, commander cmd.Commander) *View { + view := &View{ + registry: registry, + gameStateService: gameStateService, + mod: mod, + textCache: textCache, + cp: cp, + + modalStateMachine: modalStateMachine, + guiScale: guiScale, + commander: commander, + + model: freshViewModel(), + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *View) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render renders the view. +func (view *View) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + imgui.SetNextWindowSizeV(imgui.Vec2{X: 650 * view.guiScale, Y: 400 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV("Archive", view.WindowOpen(), imgui.WindowFlagsNoCollapse) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + imgui.BeginTabBar("archive-tab") + + if imgui.BeginTabItem("Levels") { + view.renderLevelsContent() + imgui.EndTabItem() + } + if imgui.BeginTabItem("Game State") { + view.renderGameStateContent() + imgui.EndTabItem() + } + + imgui.EndTabBar() +} + +func (view *View) renderLevelsContent() { + imgui.BeginChildV("Levels", imgui.Vec2{X: -100 * view.guiScale, Y: 0}, true, 0) + for id := 0; id < archive.MaxLevels; id++ { + inMod := view.hasLevelInMod(id) + info := fmt.Sprintf("%d", id) + if !inMod { + if view.hasGameStateInMod() && (id == view.effectiveGameState().CurrentLevel()) { + // current level should really be in the archive. + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1.0, Y: 0.0, Z: 0.0, W: 1.0}) + } else { + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1.0, Y: 1.0, Z: 1.0, W: 0.8}) + } + info += " (not in mod, read-only)" + } + if imgui.SelectableV(info, id == view.model.selectedLevel, 0, imgui.Vec2{}) { + view.model.selectedLevel = id + } + if !inMod { + imgui.PopStyleColor() + } + } + imgui.EndChild() + imgui.SameLine() + imgui.BeginGroup() + if imgui.ButtonV("Clear", imgui.Vec2{X: -1, Y: 0}) { + view.requestClearLevel(view.model.selectedLevel) + } + if imgui.ButtonV("Remove", imgui.Vec2{X: -1, Y: 0}) { + view.requestRemoveLevel(view.model.selectedLevel) + } + imgui.EndGroup() +} + +func (view *View) renderGameStateContent() { + data := view.gameStateData() + modStateIsDefaulting := false + + imgui.PushItemWidth(-350 * view.guiScale) + if data.isPresent() { + modState := data.toInstance() + if !modState.IsSavegame() { + modStateIsDefaulting = modState.IsDefaulting() + if modStateIsDefaulting { + if imgui.Button("Override") { + view.requestSetGameState(citadel.DefaultGameState().Raw()) + } + if imgui.IsItemHovered() { + imgui.SetTooltip("Values of new game archives are only considered by special engines.\n" + + "Overriding them will only work in those.") + } + } else if imgui.Button("Remove") { + view.requestSetGameState(archive.ZeroGameStateData()) + } + } + } + + readOnly := false + var editState *archive.GameState + if !data.isPresent() || modStateIsDefaulting { + editState = citadel.DefaultGameState() + readOnly = true + } else { + editState = data.imported().toInstance() + } + + onChange := func() { + view.requestSetGameState(editState.Raw()) + } + if imgui.TreeNodeV("Game State: General", imgui.TreeNodeFlagsDefaultOpen|imgui.TreeNodeFlagsFramed) { + imgui.LabelText("Hacker Name", "\""+editState.HackerName(view.cp)+"\"") + view.createPropertyControls(readOnly, editState.Instance, func(key string, modifier func(uint32) uint32) { + view.setInterpreterValueKeyed(editState.Instance, key, modifier) + onChange() + }) + imgui.TreePop() + } + if imgui.TreeNodeV("Inventory", imgui.TreeNodeFlagsFramed) { + view.createInventoryControls(readOnly, editState, onChange) + imgui.TreePop() + } + if imgui.TreeNodeV("Hard-/Software", imgui.TreeNodeFlagsFramed) { + view.createWareControls(readOnly, editState, onChange) + imgui.TreePop() + } + if imgui.TreeNodeV("Game State: Variables", imgui.TreeNodeFlagsFramed) { + view.createVariableControls(readOnly, editState, onChange) + imgui.TreePop() + } + if imgui.TreeNodeV("Game State: Messages", imgui.TreeNodeFlagsFramed) { + view.createMessageControls(readOnly, editState, onChange) + imgui.TreePop() + } + + imgui.PopItemWidth() +} + +type gameStateData struct{ raw []byte } + +func (data gameStateData) isPresent() bool { + return len(data.raw) > 0 +} + +func (data gameStateData) toInstance() *archive.GameState { + return archive.NewGameState(data.raw) +} + +func (data gameStateData) snapshot() gameStateData { + copied := make([]byte, len(data.raw)) + copy(copied, data.raw) + return gameStateData{raw: copied} +} + +func (data gameStateData) imported() gameStateData { + // Avoid truncating data when working with savegames. + if len(data.raw) >= archive.GameStateSize { + return data.snapshot() + } + copied := archive.ZeroGameStateData() + copy(copied, data.raw) + return gameStateData{raw: copied} +} + +func (view *View) gameStateData() gameStateData { + raw := view.mod.ModifiedBlock(resource.LangAny, ids.GameState, 0) + return gameStateData{raw: raw} +} + +func (view *View) effectiveGameState() *archive.GameState { + state := view.gameStateData().toInstance() + if state.IsDefaulting() { + return citadel.DefaultGameState() + } + return state +} + +func (view *View) hasGameStateInMod() bool { + return len(view.mod.ModifiedBlocks(resource.LangAny, ids.GameState)) > 0 +} + +func (view *View) hasLevelInMod(id int) bool { + return len(view.mod.ModifiedBlocks(resource.LangAny, ids.LevelResourcesStart.Plus(lvlids.PerLevel*id+lvlids.FirstUsed))) > 0 +} + +func (view *View) requestClearLevel(id int) { + if (id >= 0) && (id < archive.MaxLevels) { + command := setArchiveDataCommand{ + model: &view.model, + selectedLevel: id, + newData: make(map[resource.ID][]byte), + oldData: make(map[resource.ID][]byte), + } + + if !view.hasGameStateInMod() { + command.newData[ids.ArchiveName] = text.DefaultCodepage().Encode("Starting Game | by InkyBlackness HackEd") + command.newData[ids.GameState] = archive.ZeroGameStateData() + } + + param := level.EmptyLevelParameters{ + Cyberspace: world.IsConsideredCyberspaceByDefault(id), + } + effectiveGameState := view.effectiveGameState() + if id == effectiveGameState.CurrentLevel() { + posX, posY := effectiveGameState.HackerMapPosition() + param.MapModifier = func(x, y int, entry *level.TileMapEntry) { + if (x == int(posX.Tile())) && (y == int(posY.Tile())) { + entry.Type = level.TileTypeOpen + } + } + } + + levelData := level.EmptyLevelData(param) + levelIDBegin := ids.LevelResourcesStart.Plus(lvlids.PerLevel * id) + for offset, newData := range &levelData { + if (offset < lvlids.FirstUsed) || (offset >= lvlids.FirstUnused) { + continue + } + resourceID := levelIDBegin.Plus(offset) + oldData := view.mod.ModifiedBlock(resource.LangAny, resourceID, 0) + if len(oldData) > 0 { + command.oldData[resourceID] = oldData + } + if len(newData) > 0 { + command.newData[resourceID] = newData + } + } + + view.commander.Queue(command) + } +} + +func (view *View) requestRemoveLevel(id int) { + if (id >= 0) && (id < archive.MaxLevels) && view.hasLevelInMod(id) { + command := setArchiveDataCommand{ + model: &view.model, + selectedLevel: id, + newData: make(map[resource.ID][]byte), + oldData: make(map[resource.ID][]byte), + } + + levelIDBegin := ids.LevelResourcesStart.Plus(lvlids.PerLevel * id) + for offset := lvlids.FirstUsed; offset < lvlids.PerLevel; offset++ { + resourceID := levelIDBegin.Plus(offset) + oldData := view.mod.ModifiedBlock(resource.LangAny, resourceID, 0) + if len(oldData) > 0 { + command.oldData[resourceID] = oldData + } + } + + view.commander.Queue(command) + } +} + +func (view *View) requestSetGameState(newData []byte) { + command := setArchiveDataCommand{ + model: &view.model, + selectedLevel: view.model.selectedLevel, + newData: make(map[resource.ID][]byte), + oldData: make(map[resource.ID][]byte), + } + + command.oldData[ids.GameState] = view.gameStateData().snapshot().raw + command.newData[ids.GameState] = newData + + view.commander.Queue(command) +} + +func (view *View) createPropertyControls(readOnly bool, rootInterpreter *interpreters.Instance, + updater func(string, func(uint32) uint32)) { + objTypeRenderer := values.ObjectTypeControlRenderer{ + Meta: view.mod.ObjectProperties(), + TextCache: view.textCache, + } + + var processInterpreter func(string, *interpreters.Instance) + processInterpreter = func(path string, interpreter *interpreters.Instance) { + for _, key := range interpreter.Keys() { + fullKey := path + key + unifier := values.NewUnifier() + unifier.Add(int32(interpreter.Get(key))) + simplifier := values.StandardSimplifier(readOnly, fullKey, unifier, + func(modifier func(uint32) uint32) { + updater(fullKey, modifier) + }, objTypeRenderer) + + interpreter.Describe(key, simplifier) + } + + for _, key := range interpreter.ActiveRefinements() { + fullKey := path + key + if len(fullKey) > 0 { + imgui.Separator() + imgui.Text(fullKey + ":") + } + processInterpreter(fullKey+".", interpreter.Refined(key)) + } + } + processInterpreter("", rootInterpreter) +} + +func (view *View) setInterpreterValueKeyed(instance *interpreters.Instance, key string, modifier func(uint32) uint32) { + resolvedInterpreter := instance + keys := strings.Split(key, ".") + keyCount := len(keys) + if len(keys) > 1 { + for _, subKey := range keys[:keyCount-1] { + resolvedInterpreter = resolvedInterpreter.Refined(subKey) + } + } + resolvedInterpreter.Set(keys[keyCount-1], modifier(resolvedInterpreter.Get(keys[keyCount-1]))) +} + +func (view *View) createInventoryControls(readOnly bool, gameState *archive.GameState, onChange func()) { + if imgui.TreeNodeV("Weapons", imgui.TreeNodeFlagsFramed) { + for i := 0; i < archive.InventoryWeaponSlots; i++ { + if i > 0 { + imgui.Separator() + } + imgui.PushID(fmt.Sprintf("weapon%d", i)) + view.createInventoryWeaponSlotControls(readOnly, gameState.InventoryWeaponSlot(i), onChange) + imgui.PopID() + } + imgui.TreePop() + } + if imgui.TreeNodeV("Ammo", imgui.TreeNodeFlagsFramed) { + for i := 0; i < archive.AmmoTypeCount; i++ { + imgui.Separator() + imgui.PushID(fmt.Sprintf("ammo%d", i)) + view.createInventoryAmmoControls(readOnly, gameState.InventoryAmmo(i), onChange) + imgui.PopID() + } + imgui.TreePop() + } + if imgui.TreeNodeV("Grenades", imgui.TreeNodeFlagsFramed) { + for i := 0; i < archive.GrenadeTypeCount; i++ { + imgui.Separator() + imgui.PushID(fmt.Sprintf("grenade%d", i)) + view.createInventoryGrenadeControls(readOnly, gameState.InventoryGrenade(i), onChange) + imgui.PopID() + } + imgui.TreePop() + } + if imgui.TreeNodeV("Patches", imgui.TreeNodeFlagsFramed) { + for i := 0; i < archive.GrenadeTypeCount; i++ { + imgui.PushID(fmt.Sprintf("patch%d", i)) + view.createPatchStateControls(readOnly, gameState.PatchState(i), onChange) + imgui.PopID() + } + imgui.TreePop() + } + if imgui.TreeNodeV("General", imgui.TreeNodeFlagsFramed) { + for i := 0; i < archive.GeneralInventorySlotCount; i++ { + imgui.PushID(fmt.Sprintf("general%d", i)) + view.createGeneralInventoryControls(readOnly, gameState.GeneralInventorySlot(i), onChange) + imgui.PopID() + } + imgui.TreePop() + } +} + +func (view *View) createWareControls(readOnly bool, gameState *archive.GameState, onChange func()) { + if imgui.TreeNodeV("Hardware", imgui.TreeNodeFlagsFramed) { + for i := 0; i < archive.HardwareTypeCount; i++ { + imgui.PushID(fmt.Sprintf("hw%d", i)) + view.createHardwareControls(readOnly, gameState.HardwareState(i), onChange) + imgui.PopID() + } + imgui.TreePop() + } + + if imgui.TreeNodeV("Software", imgui.TreeNodeFlagsFramed) { + for i := 0; i < archive.SoftwareOffenseTypeCount; i++ { + imgui.PushID(fmt.Sprintf("swoffense%d", i)) + view.createVersionedSoftwareControls(readOnly, gameState.VersionedOffenseSoftwareState(i), 0, onChange) + imgui.PopID() + } + for i := 0; i < archive.SoftwareDefenseTypeCount; i++ { + imgui.PushID(fmt.Sprintf("swdefense%d", i)) + view.createVersionedSoftwareControls(readOnly, gameState.VersionedDefenseSoftwareState(i), 1, onChange) + imgui.PopID() + } + for i := 0; i < archive.SoftwareOneshotTypeCount; i++ { + imgui.PushID(fmt.Sprintf("swoneshot%d", i)) + view.createCountedSoftwareControls(readOnly, gameState.OneshotSoftwareState(i), 2, onChange) + imgui.PopID() + } + imgui.TreePop() + } +} + +func (view *View) createHardwareControls(readOnly bool, state archive.HardwareState, onChange func()) { + values.RenderUnifiedSliderInt(readOnly, + fmt.Sprintf("Version (%s)", view.indexedName(object.ClassHardware, state.Index)), + values.UnifierFor(state.Version()), + func(u values.Unifier) int { + return u.Unified().(int) + }, func(value int) string { + return "%d" + }, 0, 4, + func(newValue int) { + state.SetVersion(newValue) + onChange() + }) + + values.RenderUnifiedCheckboxCombo(readOnly, "Is Active", values.UnifierFor(state.IsActive()), func(newActive bool) { + state.SetActive(newActive) + onChange() + }) +} + +func (view *View) createVersionedSoftwareControls(readOnly bool, sw archive.VersionedSoftwareState, subclass int, onChange func()) { + values.RenderUnifiedSliderInt(readOnly, + fmt.Sprintf("Version (%s)", view.tripleName(object.TripleFrom(int(object.ClassSoftware), subclass, sw.Index))), + values.UnifierFor(sw.Version()), + func(u values.Unifier) int { + return u.Unified().(int) + }, func(value int) string { + return "%d" + }, 0, 0xFF, + func(newValue int) { + sw.SetVersion(newValue) + onChange() + }) +} + +func (view *View) createCountedSoftwareControls(readOnly bool, sw archive.CountedSoftwareState, subclass int, onChange func()) { + values.RenderUnifiedSliderInt(readOnly, + fmt.Sprintf("Count (%s)", view.tripleName(object.TripleFrom(int(object.ClassSoftware), subclass, sw.Index))), + values.UnifierFor(sw.Count()), + func(u values.Unifier) int { + return u.Unified().(int) + }, func(value int) string { + return "%d" + }, 0, 0xFF, + func(newValue int) { + sw.SetCount(newValue) + onChange() + }) +} + +func (view *View) createGeneralInventoryControls(readOnly bool, slot archive.GeneralInventorySlot, onChange func()) { + values.RenderUnifiedSliderInt(readOnly, + fmt.Sprintf("ObjectIndex (Slot %d)", slot.Index+1), + values.UnifierFor(slot.ObjectID()), + func(u values.Unifier) int { + return int(u.Unified().(level.ObjectID)) + }, func(value int) string { + return "%d" + }, 0, 871, // TODO: get ID limit of current level + func(newValue int) { + slot.SetObjectID(level.ObjectID(newValue)) + onChange() + }) +} + +func (view *View) createPatchStateControls(readOnly bool, patch archive.PatchState, onChange func()) { + values.RenderUnifiedSliderInt(readOnly, + fmt.Sprintf("Patch Count (%s)", view.indexedName(object.ClassDrug, patch.Index)), + values.UnifierFor(patch.Count()), + func(u values.Unifier) int { + return u.Unified().(int) + }, func(value int) string { + return "%d" + }, 0, 0xFF, + func(newValue int) { + patch.SetCount(newValue) + onChange() + }) +} + +func (view *View) createInventoryGrenadeControls(readOnly bool, grenade archive.InventoryGrenade, onChange func()) { + values.RenderUnifiedSliderInt(readOnly, + fmt.Sprintf("Grenade Count (%s)", view.indexedName(object.ClassGrenade, grenade.Index)), + values.UnifierFor(grenade.Count()), + func(u values.Unifier) int { + return u.Unified().(int) + }, func(value int) string { + return "%d" + }, 0, 0xFF, + func(newValue int) { + grenade.SetCount(newValue) + onChange() + }) + + values.RenderUnifiedSliderInt(readOnly, "Timer", values.UnifierFor(grenade.TimerSetting()), + func(u values.Unifier) int { + return int(u.Unified().(archive.GrenadeTimerSetting)) + }, func(value int) string { + setting := archive.GrenadeTimerSetting(value) + return fmt.Sprintf("%.01fs -- raw: %%d", setting.InSeconds()) + }, 0, archive.GrenadeTimerMaximum, + func(newValue int) { + grenade.SetTimerSetting(archive.GrenadeTimerSetting(newValue)) + onChange() + }) +} + +func (view *View) createInventoryAmmoControls(readOnly bool, ammo archive.InventoryAmmo, onChange func()) { + values.RenderUnifiedSliderInt(readOnly, + fmt.Sprintf("Clip Count (%s)", view.indexedName(object.ClassAmmo, ammo.Index)), + values.UnifierFor(ammo.FullClipCount()), + func(u values.Unifier) int { + return u.Unified().(int) + }, func(value int) string { + return "%d" + }, 0, 0xFF, + func(newValue int) { + ammo.SetFullClipCount(newValue) + onChange() + }) + values.RenderUnifiedSliderInt(readOnly, + "Extra rounds", + values.UnifierFor(ammo.ExtraRoundsCount()), + func(u values.Unifier) int { + return u.Unified().(int) + }, func(value int) string { + return "%d" + }, 0, 0xFF, + func(newValue int) { + ammo.SetExtraRoundsCount(newValue) + onChange() + }) +} + +func (view *View) createInventoryWeaponSlotControls(readOnly bool, slot archive.InventoryWeaponSlot, onChange func()) { + isInUse := slot.IsInUse() + inUseName := fmt.Sprintf("Weapon %d In Use", slot.Index+1) + + values.RenderUnifiedCheckboxCombo(readOnly, inUseName, values.UnifierFor(isInUse), func(newUse bool) { + if newUse { + slot.SetInUse(0, 0) + } else { + slot.SetFree() + } + onChange() + }) + if !isInUse { + return + } + + currentWeaponTriple := slot.Triple() + if imgui.BeginCombo("Type", view.tripleName(currentWeaponTriple)) { + allTypes := view.mod.ObjectProperties().TriplesInClass(currentWeaponTriple.Class) + for _, triple := range allTypes { + if imgui.SelectableV(view.tripleName(triple), triple == currentWeaponTriple, 0, imgui.Vec2{}) { + slot.SetInUse(triple.Subclass, triple.Type) + onChange() + } + } + imgui.EndCombo() + } + isEnergyWeapon := currentWeaponTriple.Subclass >= 4 + if isEnergyWeapon { + temperature, chargeAndOverload := slot.WeaponState() + values.RenderUnifiedSliderInt(readOnly, "Temperature", values.UnifierFor(int(temperature)), + func(u values.Unifier) int { + return u.Unified().(int) + }, func(value int) string { + return fmt.Sprintf("%.02f%%%% -- raw: %%d", (float32(value)/float32(lvlobj.EnergyWeaponMaxTemperature))*100) + }, 0x00, lvlobj.EnergyWeaponMaxTemperature, + func(newValue int) { + slot.SetWeaponState(byte(newValue), chargeAndOverload) + onChange() + }) + + overloadState := chargeAndOverload & 0x80 + charge := chargeAndOverload & 0x7F + values.RenderUnifiedCheckboxCombo(readOnly, "Overload", values.UnifierFor(overloadState != 0), func(newOverload bool) { + if newOverload { + slot.SetWeaponState(temperature, 0x80|charge) + } else { + slot.SetWeaponState(temperature, charge) + } + onChange() + }) + values.RenderUnifiedSliderInt(readOnly, "Charge", values.UnifierFor(int(charge)), + func(u values.Unifier) int { + return u.Unified().(int) + }, func(value int) string { + return fmt.Sprintf("%.02f%%%% -- raw: %%d", (float32(value)/float32(lvlobj.EnergyWeaponMaxEnergy))*100) + }, 0x00, 0x7F, + func(newValue int) { + slot.SetWeaponState(temperature, overloadState|byte(newValue)) + onChange() + }) + } else { + rounds, ammoType := slot.WeaponState() + values.RenderUnifiedSliderInt(readOnly, "Rounds", values.UnifierFor(int(rounds)), + func(u values.Unifier) int { + return u.Unified().(int) + }, func(value int) string { + return fmt.Sprintf("%d", value) + }, 0x00, 0xFF, + func(newValue int) { + slot.SetWeaponState(byte(newValue), ammoType) + onChange() + }) + values.RenderUnifiedCombo(readOnly, "Ammo Type", values.UnifierFor(int(ammoType)), + func(u values.Unifier) int { + return u.Unified().(int) + }, + func(value int) string { + switch value { + case 0: + return "Standard" + case 1: + return "Special" + default: + return fmt.Sprintf("Unknown %d", ammoType) + } + }, 2, + func(newValue int) { + slot.SetWeaponState(rounds, byte(newValue)) + onChange() + }) + } +} + +func (view *View) createVariableControls(readOnly bool, gameState *archive.GameState, onChange func()) { + currentContextIdentifier := view.gameStateService.VariableBaseContext() + if imgui.BeginCombo("Base Context", variableContextList[currentContextIdentifier].title) { + contextIdentifiers := edit.VariableContextIdentifiers() + for _, identifier := range contextIdentifiers { + contextInfo := variableContextList[identifier] + if imgui.SelectableV(contextInfo.title, identifier == currentContextIdentifier, 0, imgui.Vec2{}) { + view.gameStateService.SetVariableBaseContext(identifier) + } + if imgui.IsItemHovered() { + imgui.SetTooltip(contextInfo.info) + } + } + imgui.EndCombo() + } + if imgui.IsItemHovered() { + imgui.SetTooltip("The base context defines to what variables default to\n" + + "in case they are not overridden by the project.") + } + + if imgui.Button("Remove All Overrides") { + _ = view.gameStateService.DefaultAllVariables() + } + if imgui.IsItemHovered() { + imgui.SetTooltip("Removes all overrides and their parameters.") + } + if !readOnly { + imgui.SameLine() + if imgui.Button("Reset All") { + for i := 0; i < archive.BooleanVarCount; i++ { + gameState.SetBooleanVar(i, view.gameStateService.BooleanVariable(i).ResetValueBool()) + } + for i := 0; i < archive.IntegerVarCount; i++ { + gameState.SetIntegerVar(i, view.gameStateService.IntegerVariable(i).ResetValueInt()) + } + onChange() + } + if imgui.IsItemHovered() { + imgui.SetTooltip("Resets all variables to their configured initial value.\n" + + "To reset a single variable, hover over the specific variable and use the context menu.") + } + } + + isSavegame := gameState.IsSavegame() + if imgui.TreeNodeV("Boolean Variables", imgui.TreeNodeFlagsFramed) { + intConverter := func(u values.Unifier) int { + bValue := u.Unified().(bool) + if bValue { + return 1 + } + return 0 + } + defaultNames := map[int]string{0: "False", 1: "True"} + + for i := 0; i < archive.BooleanVarCount; i++ { + varIndex := i + info := view.gameStateService.BooleanVariable(varIndex) + + varReadOnly := readOnly || (info.Hardcoded && !isSavegame) + varLabel := fmt.Sprintf("Var%03d", varIndex) + varName := fmt.Sprintf("Var%03d: %s", varIndex, info.Name) + varUnifier := values.UnifierFor(gameState.BooleanVar(varIndex)) + + values.RenderUnifiedCombo(varReadOnly, varName, varUnifier, intConverter, + func(value int) string { + name, found := info.ValueNames[int16(value)] + if found { + return name + } + return defaultNames[value] + }, + 2, + func(newValue int) { + gameState.SetBooleanVar(varIndex, newValue != 0) + onChange() + }) + + if imgui.BeginPopupContextItemV(varLabel+"-Popup", 1) { + if imgui.Selectable("Edit info...") { + view.modalStateMachine.SetState(&editBooleanVariableDialog{ + machine: view.modalStateMachine, + view: view, + service: view.gameStateService, + index: varIndex, + }) + } + if !varReadOnly { + imgui.Separator() + if imgui.Selectable("Reset") { + gameState.SetBooleanVar(varIndex, info.ResetValueBool()) + onChange() + } + } + imgui.EndPopup() + } else if imgui.IsItemHovered() && (len(info.Description) > 0) { + imgui.SetTooltip(info.Description) + } + } + imgui.TreePop() + } + if imgui.TreeNodeV("Integer Variables", imgui.TreeNodeFlagsFramed) { + intConverter := func(u values.Unifier) int { + return int(u.Unified().(int16)) + } + for i := 0; i < archive.IntegerVarCount; i++ { + varIndex := i + info := view.gameStateService.IntegerVariable(varIndex) + + varReadOnly := readOnly || (info.Hardcoded && !isSavegame) + varLabel := fmt.Sprintf("Var%02d", varIndex) + varName := fmt.Sprintf("Var%02d: %s", varIndex, info.Name) + varUnifier := values.UnifierFor(gameState.IntegerVar(varIndex)) + changeHandler := func(newValue int) { + gameState.SetIntegerVar(varIndex, int16(newValue)) + onChange() + } + + switch { + case archive.IsRandomIntegerVariable(varIndex): + values.RenderUnifiedSliderInt(readOnly, varName, varUnifier, + func(u values.Unifier) int { return int(numbers.FromBinaryCodedDecimal(uint16(intConverter(u)))) }, + func(value int) string { return "%03d" }, + 0, 999, + func(newValue int) { + changeHandler(int(numbers.ToBinaryCodedDecimal(uint16(newValue)))) + }) + case len(info.ValueNames) > 0: + var max int16 + for key := range info.ValueNames { + if key > max { + max = key + } + } + values.RenderUnifiedCombo(varReadOnly, varName, varUnifier, intConverter, + func(value int) string { + name, found := info.ValueNames[int16(value)] + if found { + return name + } + return fmt.Sprintf("Unknown %d", value) + }, + int(max+1), + changeHandler) + default: + min := math.MinInt16 + max := math.MaxInt16 + if info.Limits != nil { + min = int(info.Limits.Minimum) + max = int(info.Limits.Maximum) + } + + values.RenderUnifiedSliderInt(varReadOnly, varName, varUnifier, intConverter, + func(value int) string { + return "%d" + }, + min, max, + changeHandler) + } + if imgui.BeginPopupContextItemV(varLabel+"-Popup", 1) { + if imgui.Selectable("Edit info...") { + view.modalStateMachine.SetState(&editIntegerVariableDialog{ + machine: view.modalStateMachine, + view: view, + service: view.gameStateService, + index: varIndex, + }) + } + if !varReadOnly { + imgui.Separator() + if imgui.Selectable("Reset") { + gameState.SetIntegerVar(varIndex, info.ResetValueInt()) + onChange() + } + } + imgui.EndPopup() + } else if imgui.IsItemHovered() && (len(info.Description) > 0) { + imgui.SetTooltip(info.Description) + } + } + imgui.TreePop() + } +} + +func (view *View) createMessageControls(readOnly bool, gameState *archive.GameState, onChange func()) { + view.createMessageControlsFor(readOnly, "EMail", + &view.model.emailIndex, archive.EMailCount, gameState.EMailState, onChange) + view.createMessageControlsFor(readOnly, "Log", + &view.model.logIndex, archive.LogCount, gameState.LogState, onChange) + view.createMessageControlsFor(readOnly, "Fragment", + &view.model.fragmentIndex, archive.FragmentCount, gameState.FragmentState, onChange) +} + +func (view *View) createMessageControlsFor(readOnly bool, name string, index *int, limit int, + retriever func(int) archive.MessageState, onChange func()) { + imgui.PushID(name) + gui.StepSliderInt(name+" Index", index, 0, limit-1) + messageState := retriever(*index) + values.RenderUnifiedCheckboxCombo(readOnly, "Received", values.UnifierFor(messageState.Received()), + func(newState bool) { + messageState.SetReceived(newState) + onChange() + }) + values.RenderUnifiedCheckboxCombo(readOnly, "Read", values.UnifierFor(messageState.Read()), + func(newState bool) { + messageState.SetRead(newState) + onChange() + }) + imgui.PopID() +} + +func (view *View) tripleName(triple object.Triple) string { + suffix := hintUnknown + linearIndex := view.mod.ObjectProperties().TripleIndex(triple) + if linearIndex >= 0 { + key := resource.KeyOf(ids.ObjectLongNames, resource.LangDefault, linearIndex) + objName, err := view.textCache.Text(key) + if err == nil { + suffix = singleLineString(objName) + } + } + return triple.String() + ": " + suffix +} + +func (view *View) indexedName(class object.Class, index int) string { + startIndex := view.mod.ObjectProperties().TripleIndex(object.TripleFrom(int(class), 0, 0)) + if startIndex >= 0 { + key := resource.KeyOf(ids.ObjectLongNames, resource.LangDefault, startIndex+index) + objName, err := view.textCache.Text(key) + if err == nil { + return singleLineString(objName) + } + } + return hintUnknown +} + +func singleLineString(str string) string { + return strings.ReplaceAll(strings.ReplaceAll(str, "\r", ""), "\n", "") +} diff --git a/modding/hacked/editor/archives/ViewModel.go b/modding/hacked/editor/archives/ViewModel.go new file mode 100644 index 0000000..5f9ce07 --- /dev/null +++ b/modding/hacked/editor/archives/ViewModel.go @@ -0,0 +1,16 @@ +package archives + +type viewModel struct { + windowOpen bool + restoreFocus bool + + selectedLevel int + + emailIndex int + logIndex int + fragmentIndex int +} + +func freshViewModel() viewModel { + return viewModel{} +} diff --git a/modding/hacked/editor/bitmaps/SetBitmapCommand.go b/modding/hacked/editor/bitmaps/SetBitmapCommand.go new file mode 100644 index 0000000..7e2b309 --- /dev/null +++ b/modding/hacked/editor/bitmaps/SetBitmapCommand.go @@ -0,0 +1,32 @@ +package bitmaps + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +type setBitmapCommand struct { + model *viewModel + + displayKey resource.Key + + resourceKey resource.Key + oldData []byte + newData []byte +} + +func (cmd setBitmapCommand) Do(modder world.Modder) error { + return cmd.perform(modder, cmd.newData) +} + +func (cmd setBitmapCommand) Undo(modder world.Modder) error { + return cmd.perform(modder, cmd.oldData) +} + +func (cmd setBitmapCommand) perform(modder world.Modder, data []byte) error { + modder.SetResourceBlock(cmd.resourceKey.Lang, cmd.resourceKey.ID, cmd.resourceKey.Index, data) + + cmd.model.restoreFocus = true + cmd.model.currentKey = cmd.displayKey + return nil +} diff --git a/modding/hacked/editor/bitmaps/View.go b/modding/hacked/editor/bitmaps/View.go new file mode 100644 index 0000000..26d4017 --- /dev/null +++ b/modding/hacked/editor/bitmaps/View.go @@ -0,0 +1,271 @@ +package bitmaps + +import ( + "fmt" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/external" + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/gui" +) + +type bitmapInfo struct { + title string + languageSpecific bool + bitmapType bitmap.Type + bitmapFlags bitmap.Flag +} + +var knownBitmapTypes = map[resource.ID]bitmapInfo{ + ids.MfdDataBitmaps: {title: "MFD Data Images", languageSpecific: true, bitmapType: bitmap.TypeCompressed8Bit, bitmapFlags: bitmap.FlagTransparent}, + ids.ObjectMaterialBitmaps: {title: "Object Materials", languageSpecific: false, bitmapType: bitmap.TypeFlat8Bit, bitmapFlags: 0}, + ids.ObjectTextureBitmaps: {title: "Object Textures", languageSpecific: false, bitmapType: bitmap.TypeFlat8Bit, bitmapFlags: 0}, + ids.IconBitmaps: {title: "Wall Icons", languageSpecific: false, bitmapType: bitmap.TypeCompressed8Bit, bitmapFlags: bitmap.FlagTransparent}, + ids.GraffitiBitmaps: {title: "Graffiti", languageSpecific: false, bitmapType: bitmap.TypeCompressed8Bit, bitmapFlags: bitmap.FlagTransparent}, + ids.ScreenTextures: {title: "Screens", languageSpecific: false, bitmapType: bitmap.TypeCompressed8Bit, bitmapFlags: 0}, +} + +var knownBitmapTypesOrder = []resource.ID{ + ids.MfdDataBitmaps, + ids.ObjectMaterialBitmaps, + ids.ObjectTextureBitmaps, + ids.IconBitmaps, + ids.GraffitiBitmaps, + ids.ScreenTextures, +} + +// View provides edit controls for bitmaps. +type View struct { + mod *world.Mod + imageCache *graphics.TextureCache + paletteCache *graphics.PaletteCache + + modalStateMachine gui.ModalStateMachine + clipboard external.Clipboard + guiScale float32 + commander cmd.Commander + + model viewModel +} + +// NewBitmapsView returns a new instance. +func NewBitmapsView(mod *world.Mod, imageCache *graphics.TextureCache, paletteCache *graphics.PaletteCache, + modalStateMachine gui.ModalStateMachine, clipboard external.Clipboard, + guiScale float32, commander cmd.Commander) *View { + view := &View{ + mod: mod, + imageCache: imageCache, + paletteCache: paletteCache, + + modalStateMachine: modalStateMachine, + clipboard: clipboard, + guiScale: guiScale, + commander: commander, + + model: freshViewModel(), + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *View) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render renders the view. +func (view *View) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + imgui.SetNextWindowSizeV(imgui.Vec2{X: 800 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV("Bitmaps", view.WindowOpen(), imgui.WindowFlagsNoCollapse|imgui.WindowFlagsHorizontalScrollbar) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + if imgui.BeginChildV("Properties", imgui.Vec2{X: 350 * view.guiScale, Y: 0}, false, 0) { + imgui.PushItemWidth(-150 * view.guiScale) + if imgui.BeginCombo("Bitmap Type", knownBitmapTypes[view.model.currentKey.ID].title) { + for _, id := range knownBitmapTypesOrder { + if imgui.SelectableV(knownBitmapTypes[id].title, id == view.model.currentKey.ID, 0, imgui.Vec2{}) { + view.model.currentKey.ID = id + view.model.currentKey.Index = 0 + } + } + imgui.EndCombo() + } + selectedType := knownBitmapTypes[view.model.currentKey.ID] + if selectedType.languageSpecific { + if imgui.BeginCombo("Language", view.model.currentKey.Lang.String()) { + languages := resource.Languages() + for _, lang := range languages { + if imgui.SelectableV(lang.String(), lang == view.model.currentKey.Lang, 0, imgui.Vec2{}) { + view.model.currentKey.Lang = lang + } + } + imgui.EndCombo() + } + } else { + view.model.currentKey.Lang = resource.LangAny + } + + info, _ := ids.Info(view.model.currentKey.ID) + + gui.StepSliderInt("Index", &view.model.currentKey.Index, 0, info.MaxCount-1) + + render.TextureSelector("###"+"IndexBitmap", -1, view.guiScale, info.MaxCount, + view.model.currentKey.Index, view.imageCache, + view.indexedResourceKey, + func(index int) string { return fmt.Sprintf("%d", index) }, + func(newValue int) { + view.model.currentKey.Index = newValue + }) + + tex, err := view.imageCache.Texture(view.currentResourceKey()) + + if imgui.Button("Clear") { + view.requestClear(selectedType) + } + imgui.SameLine() + if imgui.Button("Import") { + view.requestImport(selectedType) + } + if err == nil { + imgui.SameLine() + if imgui.Button("Export") { + view.requestExport() + } + if view.hasModCurrentBitmap() { + imgui.SameLine() + if imgui.Button("Remove") { + view.requestSetBitmapData(nil) + } + } + + width, height := tex.Size() + imgui.LabelText("Width", fmt.Sprintf("%d", int(width))) + imgui.LabelText("Height", fmt.Sprintf("%d", int(height))) + } + + imgui.PopItemWidth() + } + imgui.EndChild() + imgui.SameLine() + render.TextureImage("Big texture", view.imageCache, view.currentResourceKey(), imgui.Vec2{X: 320 * view.guiScale, Y: 240 * view.guiScale}) +} + +func (view *View) currentResourceKey() resource.Key { + return view.indexedResourceKey(view.model.currentKey.Index) +} + +func (view *View) indexedResourceKey(index int) resource.Key { + key := view.model.currentKey + info, _ := ids.Info(view.model.currentKey.ID) + if !info.List { + key.ID = key.ID.Plus(index) + key.Index = 0 + } else { + key.Index = index + } + return key +} + +func (view *View) hasModCurrentBitmap() bool { + key := view.currentResourceKey() + return len(view.mod.ModifiedBlock(key.Lang, key.ID, key.Index)) > 0 +} + +func (view *View) requestExport() { + key := view.currentResourceKey() + texture, err := view.imageCache.Texture(key) + if err != nil { + return + } + palette, err := view.paletteCache.Palette(0) + if err != nil { + return + } + rawPalette := palette.Palette() + filename := fmt.Sprintf("%05d_%03d_%s.png", key.ID.Value(), key.Index, key.Lang.String()) + width, height := texture.Size() + bmp := bitmap.Bitmap{ + Header: bitmap.Header{ + Width: int16(width), + Height: int16(height), + }, + Pixels: texture.PixelData(), + Palette: &rawPalette, + } + + external.ExportImage(view.modalStateMachine, filename, bmp) +} + +func (view *View) requestImport(bmpInfo bitmapInfo) { + paletteRetriever := func() (bitmap.Palette, error) { + palette, err := view.paletteCache.Palette(0) + if err != nil { + return bitmap.Palette{}, err + } + return palette.Palette(), nil + } + external.ImportImage(view.modalStateMachine, paletteRetriever, func(bmp bitmap.Bitmap) { + view.requestSetBitmap(bmp, bmpInfo) + }) +} + +func (view *View) requestClear(bmpInfo bitmapInfo) { + bmp := bitmap.Bitmap{ + Header: bitmap.Header{ + Width: 1, + Height: 1, + }, + Pixels: []byte{0x00}, + } + view.requestSetBitmap(bmp, bmpInfo) +} + +func (view *View) requestSetBitmap(bmp bitmap.Bitmap, bmpInfo bitmapInfo) { + highestBitShift := func(value int16) (result byte) { + if value != 0 { + for (value >> result) != 1 { + result++ + } + } + return + } + + bmp.Header.Flags = bmpInfo.bitmapFlags + bmp.Header.Type = bmpInfo.bitmapType + bmp.Header.WidthFactor = highestBitShift(bmp.Header.Width) + bmp.Header.HeightFactor = highestBitShift(bmp.Header.Height) + bmp.Header.Stride = uint16(bmp.Header.Width) + data := bitmap.Encode(&bmp, 0) + view.requestSetBitmapData(data) +} + +func (view *View) requestSetBitmapData(newData []byte) { + resourceKey := view.currentResourceKey() + + command := setBitmapCommand{ + displayKey: view.model.currentKey, + model: &view.model, + + resourceKey: resourceKey, + oldData: view.mod.ModifiedBlock(resourceKey.Lang, resourceKey.ID, resourceKey.Index), + newData: newData, + } + view.commander.Queue(command) +} diff --git a/modding/hacked/editor/bitmaps/ViewModel.go b/modding/hacked/editor/bitmaps/ViewModel.go new file mode 100644 index 0000000..0e9b35a --- /dev/null +++ b/modding/hacked/editor/bitmaps/ViewModel.go @@ -0,0 +1,19 @@ +package bitmaps + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +type viewModel struct { + windowOpen bool + restoreFocus bool + + currentKey resource.Key +} + +func freshViewModel() viewModel { + return viewModel{ + currentKey: resource.KeyOf(ids.MfdDataBitmaps, resource.LangDefault, 0), + } +} diff --git a/modding/hacked/editor/doc.go b/modding/hacked/editor/doc.go new file mode 100644 index 0000000..9440b37 --- /dev/null +++ b/modding/hacked/editor/doc.go @@ -0,0 +1,4 @@ +/* +Package editor and its sub-packages contain the editor-specifics of hacked. +*/ +package editor diff --git a/modding/hacked/editor/external/Clipboard.go b/modding/hacked/editor/external/Clipboard.go new file mode 100644 index 0000000..2438a25 --- /dev/null +++ b/modding/hacked/editor/external/Clipboard.go @@ -0,0 +1,9 @@ +package external + +// Clipboard represents a temporary storage. +type Clipboard interface { + // String returns the current value of the clipboard, if it is compatible with UTF-8. + String() (string, error) + // SetString sets the current value of the clipboard as UTF-8 string. + SetString(value string) +} diff --git a/modding/hacked/editor/external/Export.go b/modding/hacked/editor/external/Export.go new file mode 100644 index 0000000..6bb4aa8 --- /dev/null +++ b/modding/hacked/editor/external/Export.go @@ -0,0 +1,71 @@ +package external + +import ( + "image" + "image/png" + "os" + "path/filepath" + + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/audio/wav" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ui/gui" +) + +// Export starts an export dialog series, calling the given callback with a folder name. +func Export(machine gui.ModalStateMachine, info string, callback func(string), lastFailed bool) { + machine.SetState(&exportStartState{ + machine: machine, + callback: callback, + info: info, + withError: lastFailed, + }) +} + +// ExportAudio is a helper wrapper for exporting audio. +func ExportAudio(machine gui.ModalStateMachine, filename string, sound audio.L8) { + info := "File to be written: " + filename + var dirHandler func(string) + + dirHandler = func(dirname string) { + writer, err := os.Create(filepath.Join(dirname, filename)) + if err != nil { + Export(machine, info, dirHandler, true) + return + } + defer func() { _ = writer.Close() }() + err = wav.Save(writer, sound.SampleRate, sound.Samples) + if err != nil { + Export(machine, info, dirHandler, true) + } + } + + Export(machine, info, dirHandler, false) +} + +// ExportImage is a helper wrapper for exporting single images. +func ExportImage(machine gui.ModalStateMachine, filename string, bmp bitmap.Bitmap) { + info := "File to be written: " + filename + var exportTo func(string) + + exportTo = func(dirname string) { + writer, err := os.Create(filepath.Join(dirname, filename)) + if err != nil { + Export(machine, "Could not create file.\n"+info, exportTo, true) + return + } + defer func() { _ = writer.Close() }() + + imageRect := image.Rect(0, 0, int(bmp.Header.Width), int(bmp.Header.Height)) + imagePal := bmp.Palette.ColorPalette(false) + paletted := image.NewPaletted(imageRect, imagePal) + paletted.Pix = bmp.Pixels + err = png.Encode(writer, paletted) + if err != nil { + Export(machine, info, exportTo, true) + return + } + } + + Export(machine, info, exportTo, false) +} diff --git a/modding/hacked/editor/external/ExportStartState.go b/modding/hacked/editor/external/ExportStartState.go new file mode 100644 index 0000000..b287c33 --- /dev/null +++ b/modding/hacked/editor/external/ExportStartState.go @@ -0,0 +1,32 @@ +package external + +import ( + "time" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type exportStartState struct { + machine gui.ModalStateMachine + callback func(string) + info string + withError bool +} + +func (state exportStartState) Render() { + imgui.OpenPopup("Export") + nextState := &exportWaitingState{ + machine: state.machine, + callback: state.callback, + info: state.info, + } + if state.withError { + nextState.failureTime = time.Now() + } + state.machine.SetState(nextState) +} + +func (state exportStartState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/external/ExportWaitingState.go b/modding/hacked/editor/external/ExportWaitingState.go new file mode 100644 index 0000000..eec3aa1 --- /dev/null +++ b/modding/hacked/editor/external/ExportWaitingState.go @@ -0,0 +1,81 @@ +package external + +import ( + "os" + "time" + + "github.com/inkyblackness/imgui-go/v3" + "github.com/sqweek/dialog" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type exportWaitingState struct { + machine gui.ModalStateMachine + callback func(string) + info string + + failureTime time.Time +} + +func (state *exportWaitingState) Render() { + if imgui.BeginPopupModalV("Export", nil, + imgui.WindowFlagsNoResize|imgui.WindowFlagsNoMove|imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) { + imgui.Text("Waiting for folder.") + if !state.failureTime.IsZero() { + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1}) + imgui.Text("Previous attempt failed, could not save file.\nPlease check and try again.") + imgui.PopStyleColor() + if time.Since(state.failureTime).Seconds() > 5 { + state.failureTime = time.Time{} + } + } + imgui.Text(`From your file browser drag'n'drop the folder +where to save the data into the editor window. +`) + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1}) + imgui.Text("Saving will potentially overwrite existing files in the folder.") + imgui.PopStyleColor() + imgui.Text(state.info) + imgui.Separator() + if imgui.Button("Browse...") { + dlgBuilder := dialog.Directory() + filename, err := dlgBuilder.Browse() + if err == nil { + state.HandleFiles([]string{filename}) + } + } + imgui.SameLine() + if imgui.Button("Cancel") { + state.machine.SetState(nil) + imgui.CloseCurrentPopup() + } + imgui.EndPopup() + } else { + state.machine.SetState(nil) + } +} + +func (state *exportWaitingState) HandleFiles(names []string) { + dirPath, ok := state.verifyDir(names) + if ok { + state.machine.SetState(nil) + state.callback(dirPath) + } else { + state.failureTime = time.Now() + } +} + +func (state exportWaitingState) verifyDir(names []string) (string, bool) { + if len(names) != 1 { + return "", false + } + fileInfo, err := os.Stat(names[0]) + if err != nil { + return "", false + } + if !fileInfo.IsDir() { + return "", false + } + return names[0], true +} diff --git a/modding/hacked/editor/external/FileDialog.go b/modding/hacked/editor/external/FileDialog.go new file mode 100644 index 0000000..6fbe589 --- /dev/null +++ b/modding/hacked/editor/external/FileDialog.go @@ -0,0 +1,137 @@ +package external + +import ( + "github.com/inkyblackness/imgui-go/v3" + "github.com/sqweek/dialog" + + "github.com/inkyblackness/hacked/ui/gui" +) + +// SingleFileHandler is a callback for a single selected file. +type SingleFileHandler func(string) error + +type singleFileRunner func(*dialog.FileBuilder) (string, error) + +// SaveFile starts a dialog series to save a file. +func SaveFile(machine gui.ModalStateMachine, types []TypeInfo, callback SingleFileHandler) { + machine.SetState(&fileStartState{ + machine: machine, + title: "Save", + typeInfo: types, + runner: func(builder *dialog.FileBuilder) (string, error) { return builder.Save() }, + callback: callback, + }) +} + +// LoadFile starts a dialog series to load a file. +func LoadFile(machine gui.ModalStateMachine, types []TypeInfo, callback SingleFileHandler) { + machine.SetState(&fileStartState{ + machine: machine, + title: "Load", + typeInfo: types, + runner: func(builder *dialog.FileBuilder) (string, error) { return builder.Load() }, + callback: callback, + }) +} + +type fileStartState struct { + machine gui.ModalStateMachine + title string + typeInfo []TypeInfo + runner singleFileRunner + callback SingleFileHandler +} + +func (state fileStartState) HandleFiles(names []string) { +} + +func (state fileStartState) Render() { + imgui.OpenPopup(state.title) + nextState := &fileWaitingState{ + machine: state.machine, + title: state.title, + typeInfo: state.typeInfo, + runner: state.runner, + callback: state.callback, + } + state.machine.SetState(nextState) +} + +type fileWaitingState struct { + machine gui.ModalStateMachine + title string + typeInfo []TypeInfo + runner singleFileRunner + callback SingleFileHandler + + renderCount int + shouldOpenDialog bool + problem string +} + +func (state *fileWaitingState) HandleFiles(names []string) { +} + +func (state *fileWaitingState) Render() { + if imgui.BeginPopupModalV(state.title, nil, + imgui.WindowFlagsNoResize|imgui.WindowFlagsNoMove|imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) { + if len(state.problem) == 0 { + imgui.Text("Please use popup dialog...") + } else { + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1}) + imgui.Text(state.problem) + imgui.PopStyleColor() + } + state.renderControls() + imgui.EndPopup() + + state.renderCount++ + if state.renderCount == 3 { + state.shouldOpenDialog = true + } + + if state.shouldOpenDialog { + state.shouldOpenDialog = false + state.openDialog() + } + } else { + state.machine.SetState(nil) + } +} + +func (state *fileWaitingState) renderControls() { + imgui.Separator() + if imgui.Button("Browse...") { + state.shouldOpenDialog = true + } + imgui.SameLine() + if imgui.Button("Cancel") { + state.machine.SetState(nil) + imgui.CloseCurrentPopup() + } +} + +func (state *fileWaitingState) openDialog() { + dlgBuilder := dialog.File() + for _, info := range state.typeInfo { + dlgBuilder = dlgBuilder.Filter(info.Title, info.Extensions...) + } + dlgBuilder = dlgBuilder.Filter("All files (*.*)", "*") + + filename, err := state.runner(dlgBuilder) + state.machine.SetState(nil) + if err != nil { + return + } + state.reportFile(filename) +} + +func (state *fileWaitingState) reportFile(filename string) { + state.problem = "" + + err := state.callback(filename) + if err != nil { + state.problem = "Previous attempt failed.\nPlease check and try again.\nReason:\n" + err.Error() + state.machine.SetState(state) + } +} diff --git a/modding/hacked/editor/external/Import.go b/modding/hacked/editor/external/Import.go new file mode 100644 index 0000000..8518366 --- /dev/null +++ b/modding/hacked/editor/external/Import.go @@ -0,0 +1,117 @@ +package external + +import ( + "image" + "image/color" + "math" + "os" + + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/audio/wav" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ui/gui" +) + +// Import starts an import dialog series, calling the given callback with a file name. +func Import(machine gui.ModalStateMachine, info string, types []TypeInfo, callback func(string), lastFailed bool) { + machine.SetState(&importStartState{ + machine: machine, + callback: callback, + info: info, + typeInfo: types, + withError: lastFailed, + }) +} + +// ImportAudio is a helper to handle audio file import. The callback is called with the loaded audio. +func ImportAudio(machine gui.ModalStateMachine, callback func(l8 audio.L8)) { + info := "File must be a WAV file, 22050 Hz, 8-bit or 16-bit, uncompressed." + types := []TypeInfo{{Title: "Audio files (*.wav)", Extensions: []string{"wav"}}} + var fileHandler func(string) + + fileHandler = func(filename string) { + reader, err := os.Open(filename) + if err != nil { + Import(machine, info, types, fileHandler, true) + return + } + defer func() { _ = reader.Close() }() + sound, err := wav.Load(reader) + if err != nil { + Import(machine, info, types, fileHandler, true) + return + } + callback(sound) + } + + Import(machine, info, types, fileHandler, false) +} + +// ImportImage is a helper to handle image file import. The callback is called with the loaded image. +func ImportImage(machine gui.ModalStateMachine, paletteRetriever func() (bitmap.Palette, error), callback func(bitmap.Bitmap)) { + info := "File should be either a BMP, GIF, or a PNG file.\nPaletted images matching game palette are taken 1:1,\nothers are mapped closest fitting." + types := []TypeInfo{{Title: "Image files (*.bmp, *.gif, *.png)", Extensions: []string{"bmp", "gif", "png"}}} + var fileHandler func(string) + + fileHandler = func(filename string) { + reader, err := os.Open(filename) + if err != nil { + Import(machine, "Could not open file.\n"+info, types, fileHandler, true) + return + } + defer func() { _ = reader.Close() }() + img, _, err := image.Decode(reader) + if err != nil { + Import(machine, "File not recognized as image.\n"+info, types, fileHandler, true) + return + } + + var bmp bitmap.Bitmap + importMapped := true + rawPalette, err := paletteRetriever() + if err != nil { + Import(machine, "Can not import image without having a palette loaded.\n"+info, types, fileHandler, true) + return + } + if palettedImg, isPaletted := img.(image.PalettedImage); isPaletted { + imgPalette, hasPalette := palettedImg.ColorModel().(color.Palette) + if hasPalette && paletteMatches(imgPalette, rawPalette.ColorPalette(false)) { + bounds := img.Bounds() + + bmp.Header.Width = int16(math.Max(0, math.Min(float64(bounds.Dx()), math.MaxInt16))) + bmp.Header.Height = int16(math.Max(0, math.Min(float64(bounds.Dy()), math.MaxInt16))) + bmp.Pixels = make([]byte, int(bmp.Header.Width)*int(bmp.Header.Height)) + for row := 0; row < int(bmp.Header.Height); row++ { + for column := 0; column < int(bmp.Header.Width); column++ { + bmp.Pixels[row*int(bmp.Header.Width)+column] = palettedImg.ColorIndexAt(column, row) + } + } + importMapped = false + } + } + if importMapped { + bitmapper := bitmap.NewBitmapper(&rawPalette) + bmp = bitmapper.Map(img) + } + callback(bmp) + } + + Import(machine, info, types, fileHandler, false) +} + +func paletteMatches(imgPalette color.Palette, rawPalette color.Palette) bool { + if len(imgPalette) > len(rawPalette) { + return false + } + + for index, clr := range imgPalette { + imgR, imgG, imgB, _ := clr.RGBA() + rawR, rawG, rawB, _ := rawPalette[index].RGBA() + + if (imgR != rawR) || (imgG != rawG) || (imgB != rawB) { + return false + } + } + + return true +} diff --git a/modding/hacked/editor/external/ImportStartState.go b/modding/hacked/editor/external/ImportStartState.go new file mode 100644 index 0000000..e55a180 --- /dev/null +++ b/modding/hacked/editor/external/ImportStartState.go @@ -0,0 +1,34 @@ +package external + +import ( + "time" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type importStartState struct { + machine gui.ModalStateMachine + callback func(string) + info string + typeInfo []TypeInfo + withError bool +} + +func (state importStartState) Render() { + imgui.OpenPopup("Import") + nextState := &importWaitingState{ + machine: state.machine, + callback: state.callback, + info: state.info, + typeInfo: state.typeInfo, + } + if state.withError { + nextState.failureTime = time.Now() + } + state.machine.SetState(nextState) +} + +func (state importStartState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/external/ImportWaitingState.go b/modding/hacked/editor/external/ImportWaitingState.go new file mode 100644 index 0000000..ef5d149 --- /dev/null +++ b/modding/hacked/editor/external/ImportWaitingState.go @@ -0,0 +1,83 @@ +package external + +import ( + "os" + "time" + + "github.com/inkyblackness/imgui-go/v3" + "github.com/sqweek/dialog" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type importWaitingState struct { + machine gui.ModalStateMachine + callback func(string) + info string + typeInfo []TypeInfo + + failureTime time.Time +} + +func (state *importWaitingState) Render() { + if imgui.BeginPopupModalV("Import", nil, + imgui.WindowFlagsNoResize|imgui.WindowFlagsNoMove|imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) { + imgui.Text("Waiting for file.") + if !state.failureTime.IsZero() { + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1}) + imgui.Text("Previous attempt failed, could not load file.\nPlease check and try again.") + imgui.PopStyleColor() + if time.Since(state.failureTime).Seconds() > 5 { + state.failureTime = time.Time{} + } + } + imgui.Text(`From your file browser drag'n'drop the file +that shall be loaded into the editor window. +`) + imgui.Text(state.info) + imgui.Separator() + if imgui.Button("Browse...") { + dlgBuilder := dialog.File() + for _, info := range state.typeInfo { + dlgBuilder = dlgBuilder.Filter(info.Title, info.Extensions...) + } + dlgBuilder = dlgBuilder.Filter("All files (*.*)", "*") + filename, err := dlgBuilder.Load() + if err == nil { + state.HandleFiles([]string{filename}) + } + } + imgui.SameLine() + if imgui.Button("Cancel") { + state.machine.SetState(nil) + imgui.CloseCurrentPopup() + } + imgui.EndPopup() + } else { + state.machine.SetState(nil) + } +} + +func (state *importWaitingState) HandleFiles(names []string) { + filename, ok := state.verifyFile(names) + if ok { + state.machine.SetState(nil) + state.callback(filename) + } else { + state.failureTime = time.Now() + } +} + +func (state importWaitingState) verifyFile(names []string) (string, bool) { + if len(names) != 1 { + return "", false + } + fileInfo, err := os.Stat(names[0]) + if err != nil { + return "", false + } + if fileInfo.IsDir() { + return "", false + } + return names[0], true +} diff --git a/modding/hacked/editor/external/TypeInfo.go b/modding/hacked/editor/external/TypeInfo.go new file mode 100644 index 0000000..b47e85f --- /dev/null +++ b/modding/hacked/editor/external/TypeInfo.go @@ -0,0 +1,7 @@ +package external + +// TypeInfo describes a file. +type TypeInfo struct { + Title string + Extensions []string +} diff --git a/modding/hacked/editor/graphics/BitmapTexture.go b/modding/hacked/editor/graphics/BitmapTexture.go new file mode 100644 index 0000000..1556af6 --- /dev/null +++ b/modding/hacked/editor/graphics/BitmapTexture.go @@ -0,0 +1,91 @@ +package graphics + +import "github.com/inkyblackness/hacked/ui/opengl" + +// BitmapTexture wraps an OpenGL handle for a downloaded image. +type BitmapTexture struct { + gl opengl.OpenGL + handle uint32 + + width, height float32 + u, v float32 + + pixelData []byte +} + +func powerOfTwo(value int) int { + result := 2 + + for (result < value) && (result < 0x1000) { + result *= 2 + } + + return result +} + +// NewBitmapTexture downloads the provided raw data to OpenGL and returns a BitmapTexture instance. +func NewBitmapTexture(gl opengl.OpenGL, width, height int, pixelData []byte) *BitmapTexture { + textureWidth := powerOfTwo(width) + textureHeight := powerOfTwo(height) + tex := &BitmapTexture{ + gl: gl, + handle: gl.GenTextures(1)[0], + + width: float32(width), + height: float32(height), + + pixelData: pixelData, + } + tex.u = tex.width / float32(textureWidth) + tex.v = tex.height / float32(textureHeight) + + paddedData := make([]byte, textureWidth*textureHeight) + for y := 0; y < height; y++ { + inStart := y * width + outOffset := y * textureWidth + for x := 0; x < width; x++ { + value := pixelData[inStart+x] + paddedData[outOffset] = value + outOffset++ + } + } + + gl.BindTexture(opengl.TEXTURE_2D, tex.handle) + gl.TexImage2D(opengl.TEXTURE_2D, 0, opengl.RED, int32(textureWidth), int32(textureHeight), + 0, opengl.RED, opengl.UNSIGNED_BYTE, paddedData) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MAG_FILTER, opengl.NEAREST) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MIN_FILTER, opengl.NEAREST) + gl.GenerateMipmap(opengl.TEXTURE_2D) + gl.BindTexture(opengl.TEXTURE_2D, 0) + + return tex +} + +// Dispose releases the OpenGL texture. +func (tex *BitmapTexture) Dispose() { + if tex.handle != 0 { + tex.gl.DeleteTextures([]uint32{tex.handle}) + tex.handle = 0 + } +} + +// Handle returns the texture handle. +func (tex *BitmapTexture) Handle() uint32 { + return tex.handle +} + +// PixelData returns the original paletted pixel data. +func (tex *BitmapTexture) PixelData() []byte { + return tex.pixelData +} + +// Size returns the dimensions of the bitmap, in pixels. +func (tex *BitmapTexture) Size() (width, height float32) { + return tex.width, tex.height +} + +// UV returns the maximum U and V values for the bitmap. The bitmap will be +// stored in a power-of-two texture, which may be larger than the bitmap. +func (tex *BitmapTexture) UV() (u, v float32) { + return tex.u, tex.v +} diff --git a/modding/hacked/editor/graphics/FrameCache.go b/modding/hacked/editor/graphics/FrameCache.go new file mode 100644 index 0000000..0b10479 --- /dev/null +++ b/modding/hacked/editor/graphics/FrameCache.go @@ -0,0 +1,76 @@ +package graphics + +import ( + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ui/opengl" +) + +// FrameCacheKey is used to identify a cached frame. +type FrameCacheKey uint16 + +// FrameCache loads bitmaps with their palettes and provides OpenGL textures. +type FrameCache struct { + gl opengl.OpenGL + + textures map[FrameCacheKey]*BitmapTexture + palettes map[FrameCacheKey]*PaletteTexture + + keyCounter uint16 +} + +// NewFrameCache returns a new instance. +func NewFrameCache(gl opengl.OpenGL) *FrameCache { + cache := &FrameCache{ + gl: gl, + textures: make(map[FrameCacheKey]*BitmapTexture), + palettes: make(map[FrameCacheKey]*PaletteTexture), + } + return cache +} + +// AllocateKey returns a new key to be used with uploading a new frame. +func (cache *FrameCache) AllocateKey() FrameCacheKey { + cache.keyCounter++ + return FrameCacheKey(cache.keyCounter) +} + +// SetTexture registers a texture based on given bitmap under given key. +// The bitmap should contain a palette, otherwise it will most likely not be displayed. +func (cache *FrameCache) SetTexture(key FrameCacheKey, width, height uint16, pixels []byte, palette *bitmap.Palette) { + cache.DropTextureForKey(key) + tex := NewBitmapTexture(cache.gl, int(width), int(height), pixels) + cache.textures[key] = tex + if palette == nil { + return + } + pal := NewPaletteTexture(cache.gl, palette) + cache.palettes[key] = pal +} + +// DropTextureForKey removes the currently cached frame. +func (cache *FrameCache) DropTextureForKey(key FrameCacheKey) { + if tex, existing := cache.textures[key]; existing { + tex.Dispose() + delete(cache.textures, key) + } + if pal, existing := cache.palettes[key]; existing { + pal.Dispose() + delete(cache.palettes, key) + } +} + +// HandlesForKey returns the OpenGL handles for both palette and texture for given key. +func (cache FrameCache) HandlesForKey(key FrameCacheKey) (palette uint32, texture uint32) { + if tex, existing := cache.textures[key]; existing { + texture = tex.Handle() + } + if pal, existing := cache.palettes[key]; existing { + palette = pal.Handle() + } + return +} + +// Texture returns the buffered texture for given key. +func (cache *FrameCache) Texture(key FrameCacheKey) *BitmapTexture { + return cache.textures[key] +} diff --git a/modding/hacked/editor/graphics/PaletteCache.go b/modding/hacked/editor/graphics/PaletteCache.go new file mode 100644 index 0000000..14441b0 --- /dev/null +++ b/modding/hacked/editor/graphics/PaletteCache.go @@ -0,0 +1,71 @@ +package graphics + +import ( + "encoding/binary" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/opengl" +) + +// PaletteCache loads palettes and provides OpenGL textures. +type PaletteCache struct { + gl opengl.OpenGL + localizer resource.Localizer + + palettes map[resource.Key]*PaletteTexture +} + +// NewPaletteCache returns a new instance. +func NewPaletteCache(gl opengl.OpenGL, localizer resource.Localizer) *PaletteCache { + cache := &PaletteCache{ + gl: gl, + localizer: localizer, + palettes: make(map[resource.Key]*PaletteTexture), + } + return cache +} + +// InvalidateResources lets the cache remove any palette from resources that are specified in the given slice. +func (cache *PaletteCache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key, texture := range cache.palettes { + if key.ID == id { + texture.Dispose() + delete(cache.palettes, key) + } + } + } +} + +// Palette returns the palette with given index - if available. +func (cache *PaletteCache) Palette(index int) (*PaletteTexture, error) { + key := resource.KeyOf(ids.GamePalettesStart.Plus(index), resource.LangAny, 0) + pal, existing := cache.palettes[key] + if existing { + return pal, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + view, err := selector.Select(key.ID) + if err != nil { + return nil, err + } + if view.ContentType() != resource.Palette { + return nil, resource.ErrWrongType(key, resource.Palette) + } + reader, err := view.Block(key.Index) + if err != nil { + return nil, err + } + var palette bitmap.Palette + err = binary.Read(reader, binary.LittleEndian, &palette) + if err != nil { + return nil, err + } + + pal = NewPaletteTexture(cache.gl, &palette) + cache.palettes[key] = pal + + return pal, nil +} diff --git a/modding/hacked/editor/graphics/PaletteTexture.go b/modding/hacked/editor/graphics/PaletteTexture.go new file mode 100644 index 0000000..29b10c5 --- /dev/null +++ b/modding/hacked/editor/graphics/PaletteTexture.go @@ -0,0 +1,70 @@ +package graphics + +import ( + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ui/opengl" +) + +// PaletteTexture contains a palette stored as OpenGL texture. +type PaletteTexture struct { + gl opengl.OpenGL + + handle uint32 + palette bitmap.Palette +} + +// NewPaletteTexture creates a new PaletteTexture instance. +func NewPaletteTexture(gl opengl.OpenGL, palette *bitmap.Palette) *PaletteTexture { + tex := &PaletteTexture{ + gl: gl, + handle: gl.GenTextures(1)[0], + } + + tex.Update(palette) + + return tex +} + +// Dispose releases the OpenGL texture. +func (tex *PaletteTexture) Dispose() { + if tex.handle != 0 { + tex.gl.DeleteTextures([]uint32{tex.handle}) + tex.handle = 0 + } +} + +// Palette returns the original palette. +func (tex *PaletteTexture) Palette() bitmap.Palette { + return tex.palette +} + +// Handle returns the texture handle. +func (tex *PaletteTexture) Handle() uint32 { + return tex.handle +} + +// Update reloads the palette. +func (tex *PaletteTexture) Update(palette *bitmap.Palette) { + gl := tex.gl + const bytesPerRGBA = 4 + const colors = 256 + var data [colors * bytesPerRGBA]byte + + for i := 0; i < colors; i++ { + entry := palette[i] + data[i*bytesPerRGBA+0] = entry.Red + data[i*bytesPerRGBA+1] = entry.Green + data[i*bytesPerRGBA+2] = entry.Blue + data[i*bytesPerRGBA+3] = 0xFF + } + data[3] = 0x00 + + gl.BindTexture(opengl.TEXTURE_2D, tex.handle) + gl.TexImage2D(opengl.TEXTURE_2D, 0, opengl.RGBA, int32(colors), 1, 0, opengl.RGBA, opengl.UNSIGNED_BYTE, data[:]) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MAG_FILTER, opengl.NEAREST) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MIN_FILTER, opengl.NEAREST) + gl.GenerateMipmap(opengl.TEXTURE_2D) + gl.BindTexture(opengl.TEXTURE_2D, 0) + + tex.palette = *palette +} diff --git a/modding/hacked/editor/graphics/TextureCache.go b/modding/hacked/editor/graphics/TextureCache.go new file mode 100644 index 0000000..04c516d --- /dev/null +++ b/modding/hacked/editor/graphics/TextureCache.go @@ -0,0 +1,94 @@ +package graphics + +import ( + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ui/opengl" +) + +const ( + errReferenceNotFound ss1.StringError = "reference not existing" + errReferenceWrongDimensions ss1.StringError = "reference has wrong dimensions" +) + +// TextureCache loads bitmaps and provides OpenGL textures. +type TextureCache struct { + gl opengl.OpenGL + localizer resource.Localizer + + textures map[resource.Key]*BitmapTexture +} + +// NewTextureCache returns a new instance. +func NewTextureCache(gl opengl.OpenGL, localizer resource.Localizer) *TextureCache { + cache := &TextureCache{ + gl: gl, + localizer: localizer, + textures: make(map[resource.Key]*BitmapTexture), + } + return cache +} + +// InvalidateResources lets the cache remove any textures from resources that are specified in the given slice. +func (cache *TextureCache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key, texture := range cache.textures { + if key.ID == id { + texture.Dispose() + delete(cache.textures, key) + } + } + } +} + +// Texture returns the texture with given key - if available. +func (cache *TextureCache) Texture(key resource.Key) (*BitmapTexture, error) { + return cache.TextureReferenced(key, nil) +} + +// TextureReferenced returns the texture with given key - if available. +// Should the underlying bitmap have to be loaded, then the given reference is taken as a basis for compressed bitmaps. +// If reference is nil, then no reference is used. +func (cache *TextureCache) TextureReferenced(key resource.Key, reference *resource.Key) (*BitmapTexture, error) { + tex, existing := cache.textures[key] + if existing { + return tex, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + view, err := selector.Select(key.ID) + if err != nil { + return nil, err + } + if view.ContentType() != resource.Bitmap { + return nil, resource.ErrWrongType(key, resource.Bitmap) + } + reader, err := view.Block(key.Index) + if err != nil { + return nil, err + } + bmp, err := bitmap.DecodeReferenced(reader, func(width, height int16) ([]byte, error) { + buf := make([]byte, int(width)*int(height)) + if reference == nil { + return buf, nil + } + refTex, refExisting := cache.textures[*reference] + if !refExisting { + return nil, errReferenceNotFound + } + refWidth, refHeight := refTex.Size() + if (int16(refWidth) != width) || (int16(refHeight) != height) { + return nil, errReferenceWrongDimensions + } + copy(buf, refTex.PixelData()) + return buf, nil + }) + if err != nil { + return nil, err + } + + tex = NewBitmapTexture(cache.gl, int(bmp.Header.Width), int(bmp.Header.Height), bmp.Pixels) + cache.textures[key] = tex + + return tex, nil +} diff --git a/modding/hacked/editor/levels/BackgroundGrid.go b/modding/hacked/editor/levels/BackgroundGrid.go new file mode 100644 index 0000000..5ec73ce --- /dev/null +++ b/modding/hacked/editor/levels/BackgroundGrid.go @@ -0,0 +1,162 @@ +package levels + +import ( + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var gridVertexShaderSource = ` +#version 150 +precision mediump float; + +in vec3 vertexPosition; + +uniform mat4 viewMatrix; +uniform mat4 projectionMatrix; + +out vec4 gridColor; +out vec3 originalPosition; + +void main(void) { + gl_Position = projectionMatrix * viewMatrix * vec4(vertexPosition, 1.0); + + gridColor = vec4(0.0, 0.1, 0.0, 0.6); + originalPosition = vertexPosition; +} +` + +var gridFragmentShaderSource = ` +#version 150 +precision mediump float; + +uniform vec4 gridSize; + +in vec4 gridColor; +in vec3 originalPosition; + +out vec4 fragColor; + +float modulo(float x, float y) { + return x - y * floor(x/y); +} + +float nearGrid(float stepSize, float value) { + float remainder = modulo(value - (stepSize / 2.0), stepSize) * 2.0; + + if (remainder >= stepSize) { + remainder = (stepSize * 2.0) - remainder; + } + + return remainder / stepSize; +} + +void main(void) { + float alphaX = nearGrid(256.0, originalPosition.x); + float alphaY = nearGrid(256.0, originalPosition.y); + bool beyondX = (originalPosition.x / 256.0) >= gridSize.x || (originalPosition.x < 0.0); + bool beyondY = (originalPosition.y / 256.0) >= gridSize.y || (originalPosition.y < 0.0); + float alpha = 0.0; + + if (!beyondX && !beyondY) { + alpha = max(alphaX, alphaY); + } else if (beyondX && !beyondY) { + alpha = alphaX; + } else if (beyondY && !beyondX) { + alpha = alphaY; + } else { + alpha = min(alphaX, alphaY); + } + + alpha = pow(2.0, 10.0 * (alpha - 1.0)); + + fragColor = vec4(gridColor.rgb, gridColor.a * alpha); +} +` + +// BackgroundGrid renders a grid with transparent holes. +type BackgroundGrid struct { + context *render.Context + + program uint32 + vao *opengl.VertexArrayObject + vertexPositionBuffer uint32 + vertexPositionAttrib int32 + gridSizeUniform opengl.Vector4Uniform + viewMatrixUniform opengl.Matrix4Uniform + projectionMatrixUniform opengl.Matrix4Uniform + + lastReportedColumns int + lastReportedRows int +} + +// NewBackgroundGrid returns a new instance of BackgroundGrid. +func NewBackgroundGrid(context *render.Context) *BackgroundGrid { + gl := context.OpenGL + program, programErr := opengl.LinkNewStandardProgram(gl, gridVertexShaderSource, gridFragmentShaderSource) + if programErr != nil { + panic(opengl.NamedShaderError{Name: "BackgroundGridShader", Nested: programErr}) + } + grid := &BackgroundGrid{ + context: context, + program: program, + vao: opengl.NewVertexArrayObject(gl, program), + vertexPositionBuffer: gl.GenBuffers(1)[0], + vertexPositionAttrib: gl.GetAttribLocation(program, "vertexPosition"), + viewMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "viewMatrix")), + projectionMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "projectionMatrix")), + gridSizeUniform: opengl.Vector4Uniform(gl.GetUniformLocation(program, "gridSize")), + } + + return grid +} + +// Render renders the grid. +func (grid *BackgroundGrid) Render(columns, rows int) { + gl := grid.context.OpenGL + + grid.setGridSize(columns, rows) + + grid.vao.OnShader(func() { + grid.viewMatrixUniform.Set(gl, grid.context.ViewMatrix) + grid.projectionMatrixUniform.Set(gl, &grid.context.ProjectionMatrix) + grid.gridSizeUniform.Set(gl, &[4]float32{float32(columns), float32(rows), 0, 0}) + + gl.DrawArrays(opengl.TRIANGLES, 0, 6) + }) +} + +func (grid *BackgroundGrid) setGridSize(columns, rows int) { + if (grid.lastReportedColumns == columns) && (grid.lastReportedRows == rows) { + return + } + grid.lastReportedColumns = columns + grid.lastReportedRows = rows + + gl := grid.context.OpenGL + { + hTiles := float32(columns) + vTiles := float32(rows) + + gl.BindBuffer(opengl.ARRAY_BUFFER, grid.vertexPositionBuffer) + fineHalf := level.FineCoordinatesPerTileSide / float32(2.0) + hLimit := level.FineCoordinatesPerTileSide*hTiles + fineHalf + vLimit := level.FineCoordinatesPerTileSide*vTiles + fineHalf + var vertices = []float32{ + -fineHalf, -fineHalf, 0.0, + hLimit, -fineHalf, 0.0, + hLimit, vLimit, 0.0, + + hLimit, vLimit, 0.0, + -fineHalf, vLimit, 0.0, + -fineHalf, -fineHalf, 0.0} + gl.BufferData(opengl.ARRAY_BUFFER, len(vertices)*4, vertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + grid.vao.WithSetter(func(gl opengl.OpenGL) { + gl.EnableVertexAttribArray(uint32(grid.vertexPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, grid.vertexPositionBuffer) + gl.VertexAttribOffset(uint32(grid.vertexPositionAttrib), 3, opengl.FLOAT, false, 0, 0) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) +} diff --git a/modding/hacked/editor/levels/ColorDisplay.go b/modding/hacked/editor/levels/ColorDisplay.go new file mode 100644 index 0000000..cf73f38 --- /dev/null +++ b/modding/hacked/editor/levels/ColorDisplay.go @@ -0,0 +1,34 @@ +package levels + +import ( + "fmt" +) + +// ColorDisplay is an enumeration to how a tile should be colored. +type ColorDisplay int + +// ColorDisplay constants are listed below. +const ( + ColorDisplayNone ColorDisplay = 0 + ColorDisplayFloor ColorDisplay = 1 + ColorDisplayCeiling ColorDisplay = 2 +) + +// String returns a textual representation. +func (display ColorDisplay) String() string { + switch display { + case ColorDisplayNone: + return "None" + case ColorDisplayFloor: + return "Floor" + case ColorDisplayCeiling: + return "Ceiling" + default: + return fmt.Sprintf("Unknown%d", int(display)) + } +} + +// ColorDisplays returns all ColorDisplay constants. +func ColorDisplays() []ColorDisplay { + return []ColorDisplay{ColorDisplayNone, ColorDisplayFloor, ColorDisplayCeiling} +} diff --git a/modding/hacked/editor/levels/Constants.go b/modding/hacked/editor/levels/Constants.go new file mode 100644 index 0000000..d5edc23 --- /dev/null +++ b/modding/hacked/editor/levels/Constants.go @@ -0,0 +1,6 @@ +package levels + +const ( + hintUnknown = "???" + hintReadOnly = " (read-only)" +) diff --git a/modding/hacked/editor/levels/ControlView.go b/modding/hacked/editor/levels/ControlView.go new file mode 100644 index 0000000..2077396 --- /dev/null +++ b/modding/hacked/editor/levels/ControlView.go @@ -0,0 +1,409 @@ +package levels + +import ( + "fmt" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/gui" +) + +// ControlView is the core view for level editing. +type ControlView struct { + levels *edit.EditableLevels + levelSelection *edit.LevelSelectionService + editor *edit.LevelEditorService + + guiScale float32 + registry cmd.Registry + + textCache *text.Cache + textureCache *graphics.TextureCache + + model controlViewModel +} + +// NewControlView returns a new instance. +func NewControlView(levels *edit.EditableLevels, levelSelection *edit.LevelSelectionService, editor *edit.LevelEditorService, + guiScale float32, textCache *text.Cache, textureCache *graphics.TextureCache, + registry cmd.Registry) *ControlView { + view := &ControlView{ + levels: levels, + levelSelection: levelSelection, + editor: editor, + + guiScale: guiScale, + registry: registry, + textCache: textCache, + textureCache: textureCache, + model: freshControlViewModel(), + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *ControlView) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render renders the view. +func (view *ControlView) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + imgui.SetNextWindowSizeV(imgui.Vec2{X: 400 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver) + title := "Level Control" + readOnly := view.editor.IsReadOnly() + if readOnly { + title += hintReadOnly + } + if imgui.BeginV(title+"###Level Control", view.WindowOpen(), imgui.WindowFlagsNoCollapse) { + view.renderContent(view.editor.Level(), readOnly) + } + imgui.End() + } +} + +var levelHeights = []string{ + "32 Tiles", + "16 Tiles", + "8 Tiles", + "4 Tiles", + "2 Tiles", + "1 Tile", + "1/2 Tile", + "1/4 Tile", +} + +func (view *ControlView) renderContent(lvl *level.Level, readOnly bool) { + imgui.PushItemWidth(-200 * view.guiScale) + selectedLevel := view.levelSelection.CurrentLevelID() + if gui.StepSliderInt("Active Level", &selectedLevel, 0, archive.MaxLevels-1) { + view.levelSelection.SetCurrentLevelID(selectedLevel) + } + imgui.Separator() + levelType := "Real World" + if lvl.IsCyberspace() { + levelType = "Cyberspace" + } + imgui.LabelText("Type", levelType) + view.renderLevelHeight(lvl, readOnly) + + if !lvl.IsCyberspace() { + view.renderTextureAtlas(lvl, readOnly) + view.renderSurveillanceObjects(lvl, readOnly) + view.renderHazards(lvl, readOnly) + view.renderTextureAnimations(lvl, readOnly) + } + + imgui.PopItemWidth() +} + +func (view *ControlView) renderLevelHeight(lvl *level.Level, readOnly bool) { + _, _, currentShift := lvl.Size() + if readOnly { + imgui.LabelText("Level Height", levelHeights[currentShift]) + } else if imgui.BeginCombo("Level Height", levelHeights[currentShift]) { + for shift, height := range levelHeights { + if imgui.SelectableV(height, shift == int(currentShift), 0, imgui.Vec2{}) { + view.requestSetZShift(lvl, shift) + } + } + imgui.EndCombo() + } +} + +func (view *ControlView) textureName(index int) string { + key := resource.KeyOf(ids.TextureNames, resource.LangDefault, index) + name, err := view.textCache.Text(key) + suffix := "" + if err == nil { + suffix = ": " + name + } + return fmt.Sprintf("%3d", index) + suffix +} + +func (view *ControlView) renderTextureAtlas(lvl *level.Level, readOnly bool) { + imgui.Separator() + + atlas := lvl.TextureAtlas() + { + render.TextureSelector("Level Textures", -200, view.guiScale, + len(atlas), view.model.selectedAtlasIndex, + view.textureCache, + func(index int) resource.Key { + return resource.KeyOf(ids.LargeTextures.Plus(int(atlas[index])), resource.LangAny, 0) + }, + func(index int) string { + textureType := "(F/C/W)" + if index >= level.FloorCeilingTextureLimit { + textureType = "(walls only)" + } + return fmt.Sprintf("Atlas Index %2d ", index) + textureType + "\n" + view.textureName(int(atlas[index])) + }, func(newIndex int) { + view.model.selectedAtlasIndex = newIndex + }) + imgui.SameLine() + imgui.Text("Level Textures") + } + gameTextureIndex := -1 + if (view.model.selectedAtlasIndex >= 0) && (view.model.selectedAtlasIndex < len(atlas)) { + gameTextureIndex = int(atlas[view.model.selectedAtlasIndex]) + } + if !readOnly { + render.TextureSelector("Game Textures", -200, view.guiScale, + world.MaxWorldTextures, gameTextureIndex, + view.textureCache, + func(index int) resource.Key { + return resource.KeyOf(ids.LargeTextures.Plus(index), resource.LangAny, 0) + }, + view.textureName, + func(newIndex int) { + view.requestSetLevelTexture(lvl, view.model.selectedAtlasIndex, newIndex) + }) + imgui.SameLine() + imgui.Text("Game Textures") + } +} + +func (view *ControlView) renderSurveillanceObjects(lvl *level.Level, readOnly bool) { + imgui.Separator() + + if imgui.BeginCombo("Surveillance Object", fmt.Sprintf("Object %d", view.model.selectedSurveillanceObjectIndex)) { + for i := 0; i < level.SurveillanceObjectCount; i++ { + if imgui.SelectableV(fmt.Sprintf("Object %d", i), i == view.model.selectedSurveillanceObjectIndex, 0, imgui.Vec2{}) { + view.model.selectedSurveillanceObjectIndex = i + } + } + imgui.EndCombo() + } + sources := lvl.SurveillanceSources() + surrogates := lvl.SurveillanceSurrogates() + capacity := lvl.ObjectCapacity() + + view.renderSliderInt(readOnly, "Surveillance Source", int(sources[view.model.selectedSurveillanceObjectIndex]), + func(int) string { return "%d" }, + 0, capacity, + func(newValue int) { + view.requestSetSurveillanceSource(lvl, view.model.selectedSurveillanceObjectIndex, level.ObjectID(newValue)) + }) + view.renderSliderInt(readOnly, "Surveillance Surrogate", int(surrogates[view.model.selectedSurveillanceObjectIndex]), + func(int) string { return "%d" }, + 0, capacity, + func(newValue int) { + view.requestSetSurveillanceSurrogate(lvl, view.model.selectedSurveillanceObjectIndex, level.ObjectID(newValue)) + }) +} + +func (view *ControlView) renderHazards(lvl *level.Level, readOnly bool) { + imgui.Separator() + + parameters := lvl.Parameters() + currentCeiling := currentCeilingHazard(parameters) + if readOnly { + imgui.LabelText("Ceiling Hazard", currentCeiling.title) + } else if imgui.BeginCombo("Ceiling Hazard", currentCeiling.title) { + for _, info := range ceilingHazards { + if imgui.SelectableV(info.title, info.title == currentCeiling.title, 0, imgui.Vec2{}) { + view.requestSetCeilingHazard(lvl, info) + } + } + imgui.EndCombo() + } + view.renderSliderInt(readOnly, "Ceiling Hazard Level", int(parameters.CeilingHazardLevel), + currentCeiling.formatter, 0, 255, + func(newValue int) { + view.requestSetCeilingHazardLevel(lvl, byte(newValue)) + }) + + currentFloor := currentFloorHazard(parameters) + if readOnly { + imgui.LabelText("Floor Hazard", currentFloor.title) + } else if imgui.BeginCombo("Floor Hazard", currentFloor.title) { + for _, info := range floorHazards { + if imgui.SelectableV(info.title, info.title == currentFloor.title, 0, imgui.Vec2{}) { + view.requestSetFloorHazard(lvl, info) + } + } + imgui.EndCombo() + } + view.renderSliderInt(readOnly, "Floor Hazard Level", int(parameters.FloorHazardLevel), + currentFloor.formatter, 0, 255, + func(newValue int) { + view.requestSetFloorHazardLevel(lvl, byte(newValue)) + }) +} + +func (view *ControlView) renderTextureAnimations(lvl *level.Level, readOnly bool) { + imgui.Separator() + + animations := lvl.TextureAnimations() + selectedText := "" + if (view.model.selectedTextureAnimationIndex >= 1) && (view.model.selectedTextureAnimationIndex < len(animations)) { + selectedText = fmt.Sprintf("%d", view.model.selectedTextureAnimationIndex) + } + if imgui.BeginCombo("Texture Animation Group", selectedText) { + for i := 1; i < len(animations); i++ { + if imgui.SelectableV(fmt.Sprintf("%2d", i), view.model.selectedTextureAnimationIndex == i, 0, imgui.Vec2{}) { + view.model.selectedTextureAnimationIndex = i + } + } + imgui.EndCombo() + } + + if (view.model.selectedTextureAnimationIndex >= 1) && (view.model.selectedTextureAnimationIndex < len(animations)) { + animation := animations[view.model.selectedTextureAnimationIndex] + view.renderSliderInt(readOnly, "Texture Animation Time", int(animation.FrameTime), + func(int) string { return "%d msec" }, + 0, 1000, + func(newValue int) { + view.requestSetTextureAnimationTime(lvl, view.model.selectedTextureAnimationIndex, uint16(newValue)) + }) + view.renderSliderInt(readOnly, "Texture Animation Frame Count", int(animation.FrameCount), + func(int) string { return "%d" }, + 0, 10, + func(newValue int) { + view.requestSetTextureAnimationFrameCount(lvl, view.model.selectedTextureAnimationIndex, byte(newValue)) + }) + if readOnly { + imgui.LabelText("Texture Animation Loop Type", animation.LoopType.String()) + } else if imgui.BeginCombo("Texture Animation Loop Type", animation.LoopType.String()) { + loopTypes := level.TextureAnimationLoopTypes() + for _, loopType := range loopTypes { + if imgui.SelectableV(loopType.String(), loopType == animation.LoopType, 0, imgui.Vec2{}) { + view.requestSetTextureAnimationType(lvl, view.model.selectedTextureAnimationIndex, loopType) + } + } + imgui.EndCombo() + } + } +} + +func (view *ControlView) renderSliderInt(readOnly bool, label string, selectedValue int, + formatter func(int) string, min, max int, changeHandler func(int)) { + selectedString := formatter(selectedValue) + labelValue := fmt.Sprintf(selectedString, selectedValue) + if readOnly { + imgui.LabelText(label, labelValue) + } else if gui.StepSliderIntV(label, &selectedValue, min, max, selectedString) { + changeHandler(selectedValue) + } +} + +func (view *ControlView) requestSetZShift(lvl *level.Level, newValue int) { + lvl.SetHeightShift(level.HeightShift(newValue)) + view.patchLevelResources(lvl, func() {}) +} + +func (view *ControlView) requestSetLevelTexture(lvl *level.Level, atlasIndex, worldTextureIndex int) { + lvl.SetTextureAtlasEntry(atlasIndex, level.TextureIndex(worldTextureIndex)) + view.patchLevelResources(lvl, func() { + view.model.selectedAtlasIndex = atlasIndex + }) +} + +func (view *ControlView) requestSetSurveillanceSource(lvl *level.Level, objectIndex int, objectID level.ObjectID) { + lvl.SetSurveillanceSource(objectIndex, objectID) + view.patchLevelResources(lvl, func() { + view.model.selectedSurveillanceObjectIndex = objectIndex + }) +} + +func (view *ControlView) requestSetSurveillanceSurrogate(lvl *level.Level, objectIndex int, objectID level.ObjectID) { + lvl.SetSurveillanceSurrogate(objectIndex, objectID) + view.patchLevelResources(lvl, func() { + view.model.selectedSurveillanceObjectIndex = objectIndex + }) +} + +func (view *ControlView) requestSetCeilingHazard(lvl *level.Level, info ceilingHazardInfo) { + parameters := lvl.Parameters() + parameters.RadiationRegister = 0 + if info.radiationRegister { + parameters.RadiationRegister = 2 + } + view.patchLevelResources(lvl, func() {}) +} + +func (view *ControlView) requestSetCeilingHazardLevel(lvl *level.Level, value byte) { + lvl.Parameters().CeilingHazardLevel = value + view.patchLevelResources(lvl, func() {}) +} + +func (view *ControlView) requestSetFloorHazard(lvl *level.Level, info floorHazardInfo) { + parameters := lvl.Parameters() + parameters.BiohazardRegister = 0 + parameters.FloorHazardIsGravity = 0 + if info.isGravity { + parameters.FloorHazardIsGravity = 1 + } else if info.biohazardRegister { + parameters.BiohazardRegister = 2 + } + view.patchLevelResources(lvl, func() {}) +} + +func (view *ControlView) requestSetFloorHazardLevel(lvl *level.Level, value byte) { + lvl.Parameters().FloorHazardLevel = value + view.patchLevelResources(lvl, func() {}) +} + +func (view *ControlView) requestSetTextureAnimationTime(lvl *level.Level, index int, value uint16) { + lvl.TextureAnimations()[index].FrameTime = value + view.patchLevelResources(lvl, func() { + view.model.selectedTextureAnimationIndex = index + }) +} + +func (view *ControlView) requestSetTextureAnimationFrameCount(lvl *level.Level, index int, value byte) { + lvl.TextureAnimations()[index].FrameCount = value + view.patchLevelResources(lvl, func() { + view.model.selectedTextureAnimationIndex = index + }) +} + +func (view *ControlView) requestSetTextureAnimationType(lvl *level.Level, index int, value level.TextureAnimationLoopType) { + lvl.TextureAnimations()[index].LoopType = value + view.patchLevelResources(lvl, func() { + view.model.selectedTextureAnimationIndex = index + }) +} + +func (view *ControlView) patchLevelResources(lvl *level.Level, extraRestoreState func()) { + oldLevelID := view.levelSelection.CurrentLevelID() + extraTask := func(world.Modder) error { + extraRestoreState() + return nil + } + + err := view.registry.Register(cmd.Named("PatchLevel"), + cmd.Forward(view.restoreFocusTask()), + cmd.Forward(view.levelSelection.SetCurrentLevelIDTask(lvl.ID())), + cmd.Forward(extraTask), + cmd.Nested(func() error { return view.levels.CommitLevelChanges(lvl.ID()) }), + cmd.Reverse(extraTask), + cmd.Reverse(view.levelSelection.SetCurrentLevelIDTask(oldLevelID)), + cmd.Reverse(view.restoreFocusTask())) + if err != nil { + panic(err) + } +} + +func (view *ControlView) restoreFocusTask() cmd.Task { + return func(modder world.Modder) error { + view.model.restoreFocus = true + return nil + } +} diff --git a/modding/hacked/editor/levels/ControlViewModel.go b/modding/hacked/editor/levels/ControlViewModel.go new file mode 100644 index 0000000..4773ecb --- /dev/null +++ b/modding/hacked/editor/levels/ControlViewModel.go @@ -0,0 +1,16 @@ +package levels + +type controlViewModel struct { + selectedAtlasIndex int + selectedSurveillanceObjectIndex int + selectedTextureAnimationIndex int + + restoreFocus bool + windowOpen bool +} + +func freshControlViewModel() controlViewModel { + return controlViewModel{ + selectedTextureAnimationIndex: 1, + } +} diff --git a/modding/hacked/editor/levels/Formatters.go b/modding/hacked/editor/levels/Formatters.go new file mode 100644 index 0000000..204c0a2 --- /dev/null +++ b/modding/hacked/editor/levels/Formatters.go @@ -0,0 +1,39 @@ +package levels + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" +) + +func tileHeightFormatterFor(levelHeight level.HeightShift) func(int) string { + return func(value int) string { + tileHeight, err := levelHeight.ValueFromTileHeight(level.TileHeightUnit(value)) + tileHeightString := hintUnknown + if err == nil { + tileHeightString = fmt.Sprintf("%2.3f", tileHeight) + } + return tileHeightString + " tile(s) - raw: %d" + } +} + +func objectHeightFormatterFor(levelHeight level.HeightShift) func(int) string { + return func(value int) string { + tileHeight, err := levelHeight.ValueFromObjectHeight(level.HeightUnit(value)) + tileHeightString := hintUnknown + if err == nil { + tileHeightString = fmt.Sprintf("%2.3f", tileHeight) + } + return tileHeightString + " tile(s) - raw: %d" + } +} + +func moveTileHeightFormatterFor(levelHeight level.HeightShift) func(int) string { + normalFormatter := tileHeightFormatterFor(levelHeight) + return func(value int) string { + if (value >= 0) && (value <= int(level.TileHeightUnitMax)) { + return normalFormatter(value) + } + return "Don't change - raw: %d" + } +} diff --git a/modding/hacked/editor/levels/Highlighter.go b/modding/hacked/editor/levels/Highlighter.go new file mode 100644 index 0000000..7be5f95 --- /dev/null +++ b/modding/hacked/editor/levels/Highlighter.go @@ -0,0 +1,124 @@ +package levels + +import ( + mgl "github.com/go-gl/mathgl/mgl32" + + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var highlighterVertexShaderSource = ` +#version 150 +precision mediump float; + +in vec3 vertexPosition; + +uniform mat4 modelMatrix; +uniform mat4 viewMatrix; +uniform mat4 projectionMatrix; + +void main(void) { + gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition, 1.0); +} +` + +var highlighterFragmentShaderSource = ` +#version 150 +precision mediump float; + +uniform vec4 inColor; +out vec4 fragColor; + +void main(void) { + fragColor = inColor; +} +` + +// Highlighter draws a simple highlighting of a rectangular area. +type Highlighter struct { + context *render.Context + + program uint32 + vao *opengl.VertexArrayObject + vertexPositionBuffer uint32 + vertexPositionAttrib int32 + modelMatrixUniform opengl.Matrix4Uniform + viewMatrixUniform opengl.Matrix4Uniform + projectionMatrixUniform opengl.Matrix4Uniform + inColorUniform opengl.Vector4Uniform +} + +// NewHighlighter returns a new instance of Highlighter. +func NewHighlighter(context *render.Context) *Highlighter { + gl := context.OpenGL + program, programErr := opengl.LinkNewStandardProgram(gl, highlighterVertexShaderSource, highlighterFragmentShaderSource) + + if programErr != nil { + panic(opengl.NamedShaderError{Name: "HighlighterShader", Nested: programErr}) + } + highlighter := &Highlighter{ + context: context, + program: program, + + vao: opengl.NewVertexArrayObject(gl, program), + vertexPositionBuffer: gl.GenBuffers(1)[0], + vertexPositionAttrib: gl.GetAttribLocation(program, "vertexPosition"), + modelMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "modelMatrix")), + viewMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "viewMatrix")), + projectionMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "projectionMatrix")), + inColorUniform: opengl.Vector4Uniform(gl.GetUniformLocation(program, "inColor"))} + + { + gl.BindBuffer(opengl.ARRAY_BUFFER, highlighter.vertexPositionBuffer) + half := float32(0.5) + var vertices = []float32{ + -half, -half, 0.0, + half, -half, 0.0, + half, half, 0.0, + + half, half, 0.0, + -half, half, 0.0, + -half, -half, 0.0} + gl.BufferData(opengl.ARRAY_BUFFER, len(vertices)*4, vertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + + highlighter.vao.OnShader(func() { + gl.EnableVertexAttribArray(uint32(highlighter.vertexPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, highlighter.vertexPositionBuffer) + gl.VertexAttribOffset(uint32(highlighter.vertexPositionAttrib), 3, opengl.FLOAT, false, 0, 0) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) + + return highlighter +} + +// Dispose releases all resources. +func (highlighter *Highlighter) Dispose() { + gl := highlighter.context.OpenGL + + highlighter.vao.Dispose() + gl.DeleteBuffers([]uint32{highlighter.vertexPositionBuffer}) + gl.DeleteShader(highlighter.program) +} + +// Render renders the highlights. +func (highlighter *Highlighter) Render(positions []MapPosition, sideLength float32, color [4]float32) { + gl := highlighter.context.OpenGL + + highlighter.vao.OnShader(func() { + highlighter.viewMatrixUniform.Set(gl, highlighter.context.ViewMatrix) + highlighter.projectionMatrixUniform.Set(gl, &highlighter.context.ProjectionMatrix) + highlighter.inColorUniform.Set(gl, &color) + + for _, pos := range positions { + modelMatrix := mgl.Ident4(). + Mul4(mgl.Translate3D(float32(pos.X), float32(pos.Y), 0.0)). + Mul4(mgl.Scale3D(sideLength, sideLength, 1.0)) + + highlighter.modelMatrixUniform.Set(gl, &modelMatrix) + + gl.DrawArrays(opengl.TRIANGLES, 0, 6) + } + }) +} diff --git a/modding/hacked/editor/levels/HoverItems.go b/modding/hacked/editor/levels/HoverItems.go new file mode 100644 index 0000000..f62132c --- /dev/null +++ b/modding/hacked/editor/levels/HoverItems.go @@ -0,0 +1,135 @@ +package levels + +import ( + "sort" + + mgl "github.com/go-gl/mathgl/mgl32" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" +) + +type hoverItem interface { + Pos() MapPosition + Size() float32 + IsIn(lvl *level.Level) bool +} + +type tileHoverItem struct { + pos MapPosition +} + +func (item tileHoverItem) Pos() MapPosition { + return item.pos +} + +func (item tileHoverItem) Size() float32 { + return level.FineCoordinatesPerTileSide +} + +func (item tileHoverItem) IsIn(lvl *level.Level) bool { + return lvl.Tile(item.pos.Tile()) != nil +} + +type objectHoverItem struct { + id level.ObjectID + pos MapPosition +} + +func (item objectHoverItem) Pos() MapPosition { + return item.pos +} + +func (item objectHoverItem) Size() float32 { + return level.FineCoordinatesPerTileSide / 4 +} + +func (item objectHoverItem) IsIn(lvl *level.Level) bool { + obj := lvl.Object(item.id) + return (obj != nil) && (obj.InUse != 0) && (item.pos.X == obj.X) && (item.pos.Y == obj.Y) +} + +type hoverItems struct { + available []hoverItem + activeIndex int + activeItem hoverItem +} + +func (items *hoverItems) reset() { + items.available = nil + items.activeIndex = 0 + items.activeItem = nil +} + +func (items *hoverItems) scroll(forward bool) { + count := len(items.available) + if count > 0 { + diff := 1 + if !forward { + diff = -1 + } + items.activeIndex = (count + (items.activeIndex + diff)) % count + items.activeItem = items.available[items.activeIndex] + } +} + +func (items *hoverItems) find(lvl *level.Level, pos MapPosition) { + refVec := mgl.Vec2{float32(pos.X), float32(pos.Y)} + var distances []float32 + + items.available = nil + lvl.ForEachObject(func(id level.ObjectID, entry level.ObjectMainEntry) { + entryVec := mgl.Vec2{float32(entry.X), float32(entry.Y)} + distance := refVec.Sub(entryVec).Len() + if distance < level.FineCoordinatesPerTileSide/4 { + items.available = append(items.available, + objectHoverItem{ + id: id, + pos: MapPosition{X: entry.X, Y: entry.Y}, + }) + distances = append(distances, distance) + } + }) + items.available = append(items.available, + tileHoverItem{ + pos: MapPosition{ + X: level.CoordinateAt(pos.X.Tile(), level.FineCoordinatesPerTileSide/2), + Y: level.CoordinateAt(pos.Y.Tile(), level.FineCoordinatesPerTileSide/2), + }, + }) + distances = append(distances, level.FineCoordinatesPerTileSide) + sort.Slice(items.available, func(a, b int) bool { return distances[a] < distances[b] }) + + items.activeIndex = 0 + items.activeItem = items.available[items.activeIndex] +} + +func (items *hoverItems) validate(lvl *level.Level) { + allValid := true + for _, item := range items.available { + if !item.IsIn(lvl) { + allValid = false + break + } + } + if allValid { + return + } + + newItems := make([]hoverItem, 0, len(items.available)) + for index, item := range items.available { + isActive := index == items.activeIndex + if item.IsIn(lvl) { + if isActive { + items.activeIndex = len(newItems) + } + newItems = append(newItems, item) + } else if isActive { + items.activeIndex = 0 + } + } + items.available = newItems + items.activeItem = nil + if items.activeIndex < len(items.available) { + items.activeItem = items.available[items.activeIndex] + } +} diff --git a/modding/hacked/editor/levels/LimitedCamera.go b/modding/hacked/editor/levels/LimitedCamera.go new file mode 100644 index 0000000..39beba8 --- /dev/null +++ b/modding/hacked/editor/levels/LimitedCamera.go @@ -0,0 +1,98 @@ +package levels + +import ( + "math" + + mgl "github.com/go-gl/mathgl/mgl32" +) + +// LimitedCamera is a camera implementation with ranged controls for zooming and moving. +type LimitedCamera struct { + viewportWidth, viewportHeight float32 + minZoom, maxZoom float32 + minPos, maxPos float32 + + requestedZoomLevel float32 + viewOffsetX, viewOffsetY float32 + + viewMatrix mgl.Mat4 +} + +// NewLimitedCamera returns a new instance of a LimitedCamera. +func NewLimitedCamera(minZoom, maxZoom float32, minPos, maxPos float32) *LimitedCamera { + cam := &LimitedCamera{ + viewportWidth: 1.0, + viewportHeight: 1.0, + minZoom: minZoom, + maxZoom: maxZoom, + minPos: minPos, + maxPos: maxPos, + viewMatrix: mgl.Ident4()} + + return cam +} + +// SetViewportSize notifies the camera how big the view is. +func (cam *LimitedCamera) SetViewportSize(width, height float32) { + if (cam.viewportWidth != width) || (cam.viewportHeight != height) { + cam.viewportWidth, cam.viewportHeight = width, height + cam.updateViewMatrix() + } +} + +// ViewMatrix implements the Viewer interface. +func (cam *LimitedCamera) ViewMatrix() *mgl.Mat4 { + return &cam.viewMatrix +} + +// MoveBy adjusts the requested view offset by given delta values in world coordinates. +func (cam *LimitedCamera) MoveBy(dx, dy float32) { + cam.MoveTo(cam.viewOffsetX+dx, cam.viewOffsetY+dy) +} + +// MoveTo sets the requested view offset to the given world coordinates. +func (cam *LimitedCamera) MoveTo(worldX, worldY float32) { + cam.viewOffsetX = cam.limitValue(worldX, -cam.maxPos, cam.minPos) + cam.viewOffsetY = cam.limitValue(worldY, -cam.maxPos, cam.minPos) + cam.updateViewMatrix() +} + +// ZoomAt adjusts the requested zoom level by given delta, centered around given world position. +// Positive values zoom in. +func (cam *LimitedCamera) ZoomAt(levelDelta float32, x, y float32) { + cam.requestedZoomLevel = cam.limitValue(cam.requestedZoomLevel+levelDelta, cam.minZoom, cam.maxZoom) + + focusPoint := mgl.Vec4{x, y, 0.0, 1.0} + oldPixel := cam.viewMatrix.Mul4x1(focusPoint) + + cam.updateViewMatrix() + + newPixel := cam.viewMatrix.Mul4x1(focusPoint) + scaleFactor := cam.scaleFactor() + cam.MoveBy(-(newPixel[0]-oldPixel[0])/scaleFactor, +(newPixel[1]-oldPixel[1])/scaleFactor) +} + +func (cam *LimitedCamera) limitValue(value float32, min, max float32) float32 { + result := value + + if result < min { + result = min + } + if result > max { + result = max + } + + return result +} + +func (cam *LimitedCamera) scaleFactor() float32 { + return float32(math.Pow(2.0, float64(cam.requestedZoomLevel))) +} + +func (cam *LimitedCamera) updateViewMatrix() { + scaleFactor := cam.scaleFactor() + cam.viewMatrix = mgl.Ident4(). + Mul4(mgl.Translate3D(cam.viewportWidth/2.0, cam.viewportHeight/2.0, 0)). + Mul4(mgl.Scale3D(scaleFactor, -scaleFactor, 1.0)). + Mul4(mgl.Translate3D(cam.viewOffsetX, cam.viewOffsetY, 0)) +} diff --git a/modding/hacked/editor/levels/MapColors.go b/modding/hacked/editor/levels/MapColors.go new file mode 100644 index 0000000..3046766 --- /dev/null +++ b/modding/hacked/editor/levels/MapColors.go @@ -0,0 +1,179 @@ +package levels + +import ( + mgl "github.com/go-gl/mathgl/mgl32" + + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var mapColorsVertexShaderSource = ` +#version 150 +precision mediump float; + +in vec4 vertexColor; +in vec2 vertexPosition; + +uniform mat4 modelMatrix; +uniform mat4 viewMatrix; +uniform mat4 projectionMatrix; + +out vec4 color; + +void main(void) { + gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition.xy, 0.0, 1.0); + color = vertexColor; +} +` + +var mapColorsFragmentShaderSource = ` +#version 150 +precision mediump float; + +in vec4 color; + +out vec4 fragColor; + +void main(void) { + fragColor = color; +} +` + +// ColorQuery returns the color for the specified tile. +type ColorQuery func(level.TilePosition) [4]float32 + +// MapColors is a renderable for the tile colorings. +type MapColors struct { + context *render.Context + + program uint32 + vao *opengl.VertexArrayObject + vertexPositionBuffer uint32 + vertexPositionAttrib int32 + vertexColorBuffer uint32 + vertexColorAttrib int32 + modelMatrixUniform opengl.Matrix4Uniform + viewMatrixUniform opengl.Matrix4Uniform + projectionMatrixUniform opengl.Matrix4Uniform + + lastColorGrid [][][4]float32 + lastColorGridColumns int + lastColorGridRows int +} + +// NewMapColors returns a new instance of a renderable for tile colorings. +func NewMapColors(context *render.Context) *MapColors { + gl := context.OpenGL + program, programErr := opengl.LinkNewStandardProgram(gl, mapColorsVertexShaderSource, mapColorsFragmentShaderSource) + + if programErr != nil { + panic(opengl.NamedShaderError{Name: "MapColorsShader", Nested: programErr}) + } + renderable := &MapColors{ + context: context, + program: program, + vao: opengl.NewVertexArrayObject(gl, program), + vertexPositionBuffer: gl.GenBuffers(1)[0], + vertexPositionAttrib: gl.GetAttribLocation(program, "vertexPosition"), + vertexColorBuffer: gl.GenBuffers(1)[0], + vertexColorAttrib: gl.GetAttribLocation(program, "vertexColor"), + modelMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "modelMatrix")), + viewMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "viewMatrix")), + projectionMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "projectionMatrix")), + } + + { + top := float32(level.FineCoordinatesPerTileSide) + left := float32(0.0) + right := float32(level.FineCoordinatesPerTileSide) + bottom := float32(0.0) + + vertices := []float32{ + left, bottom, + left, top, + right, top, + + right, top, + right, bottom, + left, bottom} + + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexPositionBuffer) + gl.BufferData(opengl.ARRAY_BUFFER, len(vertices)*4, vertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + + renderable.vao.WithSetter(func(gl opengl.OpenGL) { + gl.EnableVertexAttribArray(uint32(renderable.vertexPositionAttrib)) + gl.EnableVertexAttribArray(uint32(renderable.vertexColorAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexPositionBuffer) + gl.VertexAttribOffset(uint32(renderable.vertexPositionAttrib), 2, opengl.FLOAT, false, 0, 0) + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexColorBuffer) + gl.VertexAttribOffset(uint32(renderable.vertexColorAttrib), 4, opengl.FLOAT, false, 0, 0) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) + + return renderable +} + +// Dispose releases any internal resources. +func (renderable *MapColors) Dispose() { + gl := renderable.context.OpenGL + gl.DeleteProgram(renderable.program) + gl.DeleteBuffers([]uint32{renderable.vertexPositionBuffer}) + renderable.vao.Dispose() +} + +// Render renders the renderable. +func (renderable *MapColors) Render(columnCount, rowCount int, query ColorQuery) { + gl := renderable.context.OpenGL + + if (renderable.lastColorGridColumns != columnCount) || (renderable.lastColorGridRows != rowCount) { + renderable.lastColorGrid = make([][][4]float32, rowCount+1) + for y := 0; y < rowCount; y++ { + renderable.lastColorGrid[y] = make([][4]float32, columnCount+1) + } + renderable.lastColorGrid[rowCount] = make([][4]float32, columnCount+1) + renderable.lastColorGridColumns = columnCount + renderable.lastColorGridRows = rowCount + } + for y, row := range renderable.lastColorGrid { + for x := 0; x < columnCount; x++ { + row[x] = query(level.TilePosition{X: byte(x), Y: byte(y)}) + } + } + + renderable.vao.OnShader(func() { + var colors [24]float32 + + renderable.viewMatrixUniform.Set(gl, renderable.context.ViewMatrix) + renderable.projectionMatrixUniform.Set(gl, &renderable.context.ProjectionMatrix) + + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexColorBuffer) + + for y := 0; y < rowCount; y++ { + for x := 0; x < columnCount; x++ { + colorBottomLeft := renderable.lastColorGrid[y][x] + colorBottomRight := renderable.lastColorGrid[y][x+1] + colorTopLeft := renderable.lastColorGrid[y+1][x] + colorTopRight := renderable.lastColorGrid[y+1][x+1] + + modelMatrix := mgl.Ident4(). + Mul4(mgl.Translate3D((float32(x))*level.FineCoordinatesPerTileSide, (float32(y))*level.FineCoordinatesPerTileSide, 0.0)) + renderable.modelMatrixUniform.Set(gl, &modelMatrix) + + copy(colors[0:4], colorBottomLeft[:]) + copy(colors[4:8], colorTopLeft[:]) + copy(colors[8:12], colorTopRight[:]) + copy(colors[12:16], colorTopRight[:]) + copy(colors[16:20], colorBottomRight[:]) + copy(colors[20:24], colorBottomLeft[:]) + + gl.BufferData(opengl.ARRAY_BUFFER, len(colors)*4, colors[:], opengl.STATIC_DRAW) + gl.DrawArrays(opengl.TRIANGLES, 0, 6) + } + } + + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) +} diff --git a/modding/hacked/editor/levels/MapDisplay.go b/modding/hacked/editor/levels/MapDisplay.go new file mode 100644 index 0000000..3b8e729 --- /dev/null +++ b/modding/hacked/editor/levels/MapDisplay.go @@ -0,0 +1,587 @@ +package levels + +import ( + "fmt" + + mgl "github.com/go-gl/mathgl/mgl32" + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/input" + "github.com/inkyblackness/hacked/ui/opengl" +) + +const contextMenuName = "MapContextMenu" + +// MapDisplay renders a level map. +type MapDisplay struct { + gameObjects *edit.GameObjectsService + levelSelection *edit.LevelSelectionService + editor *edit.LevelEditorService + + context render.Context + camera *LimitedCamera + guiScale float32 + + background *BackgroundGrid + textures *MapTextures + colors *MapColors + mapGrid *MapGrid + highlighter *Highlighter + icons *MapIcons + + moveCapture func(pixelX, pixelY float32) + mouseMoved bool + + positionPopupPos imgui.Vec2 + positionValid bool + position MapPosition + contextMenuRequested bool + + selectionReference *level.TilePosition + + hoverItems hoverItems +} + +// NewMapDisplay returns a new instance. +func NewMapDisplay(gameObjects *edit.GameObjectsService, levelSelection *edit.LevelSelectionService, editor *edit.LevelEditorService, + gl opengl.OpenGL, guiScale float32, + textureQuery TextureQuery) *MapDisplay { + tilesPerMapSide := float32(64) + + tileBaseLength := float32(level.FineCoordinatesPerTileSide) + tileBaseHalf := tileBaseLength / 2.0 + camLimit := tilesPerMapSide*tileBaseLength - tileBaseHalf + zoomShift := guiScale - 1.0 + zoomLevelMin := float32(-5) + zoomShift + zoomLevelMax := float32(1) + zoomShift + + display := &MapDisplay{ + gameObjects: gameObjects, + levelSelection: levelSelection, + editor: editor, + context: render.Context{ + OpenGL: gl, + ProjectionMatrix: mgl.Ident4(), + }, + camera: NewLimitedCamera(zoomLevelMin, zoomLevelMax, -tileBaseHalf, camLimit), + guiScale: guiScale, + moveCapture: func(float32, float32) {}, + } + display.context.ViewMatrix = display.camera.ViewMatrix() + display.background = NewBackgroundGrid(&display.context) + display.textures = NewMapTextures(&display.context, textureQuery) + display.colors = NewMapColors(&display.context) + display.mapGrid = NewMapGrid(&display.context) + display.highlighter = NewHighlighter(&display.context) + display.icons = NewMapIcons(&display.context) + + centerX, centerY := (tilesPerMapSide*tileBaseLength)/-2.0, (tilesPerMapSide*tileBaseLength)/-2.0 + display.camera.ZoomAt(-3+zoomShift, centerX, centerY) + display.camera.MoveTo(centerX, centerY) + + return display +} + +// Render renders the whole map display. +func (display *MapDisplay) Render( + paletteTexture *graphics.PaletteTexture, textureRetriever func(resource.Key) (*graphics.BitmapTexture, error), + textureDisplay TextureDisplay, colorDisplay ColorDisplay) { + lvl := display.editor.Level() + columns, rows, _ := lvl.Size() + + display.hoverItems.validate(lvl) + + display.background.Render(columns, rows) + if lvl.IsCyberspace() { + display.renderCyberspaceColors(lvl, columns, rows, paletteTexture, colorDisplay) + } else { + display.renderRealWorldTextures(lvl, columns, rows, paletteTexture, textureDisplay) + display.renderRealWorldShadows(lvl, columns, rows, colorDisplay) + } + display.mapGrid.Render(columns, rows, lvl) + display.renderTileSelection() + display.renderObjectBackgrounds(lvl) + display.renderObjectIcons(lvl, paletteTexture, textureRetriever) + display.renderObjectSelection() + display.renderActiveHoverItem() + display.renderPositionOverlay(lvl) + display.renderContextMenu() +} + +func (display *MapDisplay) renderCyberspaceColors(lvl *level.Level, columns int, rows int, paletteTexture *graphics.PaletteTexture, colorDisplay ColorDisplay) { + if paletteTexture == nil { + return + } + var colorQuery ColorQuery + palette := paletteTexture.Palette() + if colorDisplay == ColorDisplayFloor { + colorQuery = display.colorQueryFor(lvl, func(tile *level.TileMapEntry) [4]float32 { + rgb := palette[tile.TextureInfo.FloorPaletteIndex()] + return [4]float32{float32(rgb.Red) / 255, float32(rgb.Green) / 255, float32(rgb.Blue) / 255, 0.8} + }) + } else if colorDisplay == ColorDisplayCeiling { + colorQuery = display.colorQueryFor(lvl, func(tile *level.TileMapEntry) [4]float32 { + rgb := palette[tile.TextureInfo.CeilingPaletteIndex()] + return [4]float32{float32(rgb.Red) / 255, float32(rgb.Green) / 255, float32(rgb.Blue) / 255, 0.8} + }) + } + if colorQuery != nil { + display.colors.Render(columns, rows, colorQuery) + } +} + +func (display *MapDisplay) renderRealWorldTextures(lvl *level.Level, columns int, rows int, + paletteTexture *graphics.PaletteTexture, textureDisplay TextureDisplay) { + if paletteTexture == nil { + return + } + display.textures.Render(columns, rows, func(pos level.TilePosition) (level.TileType, level.TextureIndex, int) { + tile := lvl.Tile(pos) + if tile == nil { + return level.TileTypeSolid, 0, 0 + } + atlasIndex, textureRotations := textureDisplay.Func()(tile) + atlas := lvl.TextureAtlas() + textureIndex := level.TextureIndex(-1) + if (int(atlasIndex) >= 0) && (int(atlasIndex) < len(atlas)) { + textureIndex = atlas[atlasIndex] + } + return tile.Type, textureIndex, textureRotations + }, paletteTexture) +} + +func (display *MapDisplay) renderRealWorldShadows(lvl *level.Level, columns int, rows int, colorDisplay ColorDisplay) { + var colorQuery ColorQuery + if colorDisplay == ColorDisplayFloor { + colorQuery = display.colorQueryFor(lvl, func(tile *level.TileMapEntry) [4]float32 { + return [4]float32{0.0, 0.0, 0.0, float32(tile.Flags.ForRealWorld().FloorShadow()) / 15.0} + }) + } else if colorDisplay == ColorDisplayCeiling { + colorQuery = display.colorQueryFor(lvl, func(tile *level.TileMapEntry) [4]float32 { + return [4]float32{0.0, 0.0, 0.0, float32(tile.Flags.ForRealWorld().CeilingShadow()) / 15.0} + }) + } + if colorQuery != nil { + display.colors.Render(columns, rows, colorQuery) + } +} + +func (display *MapDisplay) renderTileSelection() { + selectedTiles := display.levelSelection.CurrentSelectedTiles() + tileMapPositions := make([]MapPosition, 0, len(selectedTiles)) + for _, pos := range selectedTiles { + tileMapPositions = append(tileMapPositions, MapPosition{ + X: level.CoordinateAt(pos.X, level.FineCoordinatesPerTileSide/2), + Y: level.CoordinateAt(pos.Y, level.FineCoordinatesPerTileSide/2), + }) + } + display.highlighter.Render(tileMapPositions, level.FineCoordinatesPerTileSide, [4]float32{0.0, 0.8, 0.2, 0.5}) +} + +func (display *MapDisplay) renderObjectBackgrounds(lvl *level.Level) { + var objects []MapPosition + lvl.ForEachObject(func(id level.ObjectID, entry level.ObjectMainEntry) { + objects = append(objects, MapPosition{X: entry.X, Y: entry.Y}) + }) + display.highlighter.Render(objects, level.FineCoordinatesPerTileSide/4, [4]float32{1.0, 1.0, 1.0, 0.3}) +} + +func (display *MapDisplay) renderObjectIcons(lvl *level.Level, paletteTexture *graphics.PaletteTexture, + textureRetriever func(resource.Key) (*graphics.BitmapTexture, error)) { + if paletteTexture == nil { + return + } + tripleInfo := display.gameObjects.BitmapInfo() + var icons []iconData + var highlightIcon iconData + var highlightID level.ObjectID + + if display.hoverItems.activeItem != nil { + objectItem, isObject := display.hoverItems.activeItem.(objectHoverItem) + if isObject { + highlightID = objectItem.id + } + } + lvl.ForEachObject(func(id level.ObjectID, entry level.ObjectMainEntry) { + triple := entry.Triple() + info, cached := tripleInfo[triple] + if !cached { + return + } + key := resource.KeyOf(ids.ObjectBitmaps, resource.LangAny, info.Start+info.IconRecommendation) + texture, err := textureRetriever(key) + if err == nil { + icon := iconData{pos: MapPosition{X: entry.X, Y: entry.Y}, texture: texture} + if highlightID == id { + highlightIcon = icon + } else { + icons = append(icons, icon) + } + } + }) + if (highlightID != 0) && (highlightIcon.texture != nil) { + icons = append(icons, highlightIcon) + } + display.icons.Render(paletteTexture, level.FineCoordinatesPerTileSide/4, icons) +} + +func (display *MapDisplay) renderObjectSelection() { + selectedObjects := display.editor.Objects() + selectedObjectHighlights := make([]MapPosition, 0, len(selectedObjects)) + for _, obj := range selectedObjects { + objPos := MapPosition{X: obj.X, Y: obj.Y} + selectedObjectHighlights = append(selectedObjectHighlights, objPos) + } + display.highlighter.Render(selectedObjectHighlights, level.FineCoordinatesPerTileSide/4, [4]float32{0.0, 0.8, 0.2, 0.5}) +} + +func (display *MapDisplay) renderActiveHoverItem() { + if display.hoverItems.activeItem == nil { + return + } + display.highlighter.Render( + []MapPosition{display.hoverItems.activeItem.Pos()}, + display.hoverItems.activeItem.Size(), + [4]float32{0.0, 0.2, 0.8, 0.3}) +} + +func (display *MapDisplay) colorQueryFor(lvl *level.Level, tileToColor func(*level.TileMapEntry) [4]float32) func(level.TilePosition) [4]float32 { + return func(pos level.TilePosition) [4]float32 { + tile := lvl.Tile(pos) + if tile == nil { + return [4]float32{} + } + return tileToColor(tile) + } +} + +func (display *MapDisplay) renderPositionOverlay(lvl *level.Level) { + imgui.SetNextWindowPosV(display.positionPopupPos, imgui.ConditionAlways, imgui.Vec2{X: 1.0, Y: 1.0}) + imgui.SetNextWindowSize(imgui.Vec2{X: 140 * display.guiScale, Y: 0}) + imgui.SetNextWindowBgAlpha(0.3) + if imgui.BeginV("Position", nil, imgui.WindowFlagsNoMove|imgui.WindowFlagsNoTitleBar|imgui.WindowFlagsNoResize|imgui.WindowFlagsAlwaysAutoResize| + imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsNoFocusOnAppearing|imgui.WindowFlagsNoNav) { + typeString := "---" + hasPos := false + var pos MapPosition + hasFloor := false + var floorRaw int + floorString := hintUnknown + hasCeiling := false + var ceilingRaw int + ceilingString := hintUnknown + + if display.hoverItems.activeItem != nil { + pos = display.hoverItems.activeItem.Pos() + hasPos = true + + if _, isTileItem := display.hoverItems.activeItem.(tileHoverItem); isTileItem { + pos = display.position // use raw cursor position for this display + typeString = "Tile" + tile := lvl.Tile(pos.Tile()) + if (tile != nil) && (tile.Type != level.TileTypeSolid) { + _, _, heightShift := lvl.Size() + floorHeight := tile.Floor.AbsoluteHeight() + floorHeightInTiles, err := heightShift.ValueFromTileHeight(floorHeight) + if err == nil { + floorString = fmt.Sprintf("%2.3f", floorHeightInTiles) + } + floorRaw = int(floorHeight) + hasFloor = true + + ceilingHeight := tile.Ceiling.AbsoluteHeight() + ceilingHeightInTiles, err := heightShift.ValueFromTileHeight(ceilingHeight) + if err == nil { + ceilingString = fmt.Sprintf("%2.3f", ceilingHeightInTiles) + } + ceilingRaw = int(ceilingHeight) + hasCeiling = true + } + } else if objectItem, isObjectItem := display.hoverItems.activeItem.(objectHoverItem); isObjectItem { + _, _, heightShift := lvl.Size() + obj := lvl.Object(objectItem.id) + if obj != nil { + typeString = fmt.Sprintf("%3d = %v", objectItem.id, obj.Triple()) + heightInTiles, err := heightShift.ValueFromObjectHeight(obj.Z) + if err == nil { + floorString = fmt.Sprintf("%2.3f", heightInTiles) + } + floorRaw = int(obj.Z) + hasFloor = true + } + } + } + imgui.Text("T: " + typeString) + if hasPos { + imgui.Text(fmt.Sprintf("X: T %2d F %3d", pos.X.Tile(), pos.X.Fine())) + imgui.Text(fmt.Sprintf("Y: T %2d F %3d", pos.Y.Tile(), pos.Y.Fine())) + } else { + imgui.Text("X: T -- F ---") + imgui.Text("Y: T -- F ---") + } + if hasFloor { + imgui.Text(fmt.Sprintf("F: %3d = %s", floorRaw, floorString)) + } else { + imgui.Text("F: -- = --.---") + } + if hasCeiling { + imgui.Text(fmt.Sprintf("C: %3d = %s", ceilingRaw, ceilingString)) + } else { + imgui.Text("C: -- = --.---") + } + imgui.End() + } +} + +func (display *MapDisplay) renderContextMenu() { + if display.contextMenuRequested { + imgui.OpenPopup(contextMenuName) + display.contextMenuRequested = false + } + if imgui.BeginPopupV(contextMenuName, imgui.PopupFlagsMouseButtonRight) { + readOnly := display.editor.IsReadOnly() + if imgui.BeginMenu("New...") { + implicitTriple := display.editor.NewObjectTriple() + canCreateImplicitClass := display.canCreateObjectOf(implicitTriple.Class) + if imgui.MenuItemV("Object", "Ctrl+2ndClick", false, canCreateImplicitClass) { + display.requestCreateNewObject(false, implicitTriple) + } + if imgui.MenuItemV("Object (at grid)", "Ctrl+Shift+2ndClick", false, canCreateImplicitClass) { + display.requestCreateNewObject(true, implicitTriple) + } + imgui.EndMenu() + } + imgui.Separator() + if imgui.MenuItemV("Delete Objects", "", false, !readOnly && display.editor.HasSelectedObjects()) { + _ = display.editor.DeleteObjects() + } + if imgui.MenuItemV("Clear Tiles", "", false, !readOnly && display.editor.HasSelectedTiles()) { + _ = display.editor.ClearTiles() + } + imgui.EndPopup() + } +} + +// WindowResized must be called to notify of a change in window geometry. +func (display *MapDisplay) WindowResized(width int, height int) { + display.context.ProjectionMatrix = mgl.Ortho2D(0.0, float32(width), float32(height), 0.0) + display.camera.SetViewportSize(float32(width), float32(height)) + display.positionPopupPos.X = float32(width) - 10.0 + display.positionPopupPos.Y = float32(height) - 10.0 +} + +func (display *MapDisplay) unprojectPixel(pixelX, pixelY float32) (x, y float32) { + pixelVec := mgl.Vec4{pixelX, pixelY, 0.0, 1.0} + invertedView := display.camera.ViewMatrix().Inv() + result := invertedView.Mul4x1(pixelVec) + + return result[0], result[1] +} + +// MouseButtonDown must be called when a button was pressed. +func (display *MapDisplay) MouseButtonDown(mouseX, mouseY float32, button uint32) { + display.updateMouseWorldPosition(mouseX, mouseY) + if button == input.MousePrimary { + lastPixelX, lastPixelY := mouseX, mouseY + + display.mouseMoved = false + display.moveCapture = func(pixelX, pixelY float32) { + lastWorldX, lastWorldY := display.unprojectPixel(lastPixelX, lastPixelY) + worldX, worldY := display.unprojectPixel(pixelX, pixelY) + + display.camera.MoveBy(worldX-lastWorldX, worldY-lastWorldY) + lastPixelX, lastPixelY = pixelX, pixelY + display.mouseMoved = true + } + } +} + +// MouseButtonUp must be called when a button was released. +func (display *MapDisplay) MouseButtonUp(mouseX, mouseY float32, button uint32, modifier input.Modifier) { + display.updateMouseWorldPosition(mouseX, mouseY) + if button == input.MousePrimary { + display.moveCapture = func(float32, float32) {} + if !display.mouseMoved && display.positionValid { + switch { + case modifier.Has(input.ModControl): + display.toggleSelectionAtActiveHoverItem() + case modifier.Has(input.ModShift) && (display.selectionReference != nil): + firstPos := *display.selectionReference + + fromX := int(firstPos.X) + fromY := int(firstPos.Y) + toX := int(display.position.X.Tile()) + toY := int(display.position.Y.Tile()) + xIncrement := 1 + yIncrement := 1 + if fromX > toX { + xIncrement = -1 + } + if fromY > toY { + yIncrement = -1 + } + toX += xIncrement + toY += yIncrement + var newList []level.TilePosition + for y := fromY; y != toY; y += yIncrement { + for x := fromX; x != toX; x += xIncrement { + newList = append(newList, level.TilePosition{X: byte(x), Y: byte(y)}) + } + } + display.levelSelection.SetCurrentSelectedTiles(newList) + display.levelSelection.SetCurrentSelectedObjects(display.objectsInTiles(newList)) + default: + display.setSelectionByActiveHoverItem() + } + } + } else if button == input.MouseSecondary { + if modifier.IsClear() { + display.contextMenuRequested = true + } else if modifier.Has(input.ModControl) { + display.requestCreateNewObject(modifier.Has(input.ModShift), display.editor.NewObjectTriple()) + } + } +} + +func (display *MapDisplay) canCreateObjectOf(class object.Class) bool { + if display.editor.IsReadOnly() { + return false + } + lvl := display.editor.Level() + return lvl.HasRoomForObjectOf(class) && display.positionValid +} + +func (display *MapDisplay) requestCreateNewObject(snapToGrid bool, triple object.Triple) { + if !display.canCreateObjectOf(triple.Class) { + return + } + pos := display.position + if snapToGrid { + toGrid := func(value byte) byte { + cornerSnapDistance := level.FineCoordinatesPerTileSide / 4 + switch { + case value < byte(cornerSnapDistance): + return 0x00 + case value >= byte(level.FineCoordinatesPerTileSide-cornerSnapDistance): + return level.FineCoordinatesPerTileSide - 1 + default: + return level.FineCoordinatesPerTileSide / 2 + } + } + pos.X = level.CoordinateAt(pos.X.Tile(), toGrid(pos.X.Fine())) + pos.Y = level.CoordinateAt(pos.Y.Tile(), toGrid(pos.Y.Fine())) + } + err := display.editor.CreateNewObject(triple, func(obj *level.ObjectMainEntry) { + obj.X = pos.X + obj.Y = pos.Y + }) + if err != nil { + panic(err) + } +} + +func (display *MapDisplay) setSelectionByActiveHoverItem() { + var tiles []level.TilePosition + var objects []level.ObjectID + if display.hoverItems.activeItem != nil { + if tileItem, isTile := display.hoverItems.activeItem.(tileHoverItem); isTile { + tiles = append(tiles, tileItem.pos.Tile()) + } else if objectItem, isObject := display.hoverItems.activeItem.(objectHoverItem); isObject { + objects = append(objects, objectItem.id) + } + } + display.levelSelection.SetCurrentSelectedTiles(tiles) + if len(tiles) > 0 { + display.selectionReference = &tiles[0] + display.levelSelection.SetCurrentSelectedObjects(display.objectsInTiles(tiles)) + } else { + display.levelSelection.SetCurrentSelectedObjects(objects) + } +} + +func (display *MapDisplay) toggleSelectionAtActiveHoverItem() { + if display.hoverItems.activeItem != nil { + if tileItem, isTile := display.hoverItems.activeItem.(tileHoverItem); isTile { + wasSelected := display.levelSelection.IsTileSelected(tileItem.pos.Tile()) + tiles := []level.TilePosition{tileItem.pos.Tile()} + if wasSelected { + display.levelSelection.RemoveCurrentSelectedTiles(tiles) + display.levelSelection.RemoveCurrentSelectedObjects(display.objectsInTiles(tiles)) + } else { + display.levelSelection.AddCurrentSelectedTiles(tiles) + display.levelSelection.AddCurrentSelectedObjects(display.objectsInTiles(tiles)) + } + } else if objectItem, isObject := display.hoverItems.activeItem.(objectHoverItem); isObject { + display.levelSelection.ToggleObjectSelection([]level.ObjectID{objectItem.id}) + } + } +} + +func (display *MapDisplay) objectsInTiles(tiles []level.TilePosition) []level.ObjectID { + tilesContain := func(pos level.TilePosition) bool { + for _, entry := range tiles { + if entry == pos { + return true + } + } + return false + } + + var objects []level.ObjectID + lvl := display.editor.Level() + lvl.ForEachObject(func(id level.ObjectID, entry level.ObjectMainEntry) { + if tilesContain(entry.TilePosition()) { + objects = append(objects, id) + } + }) + return objects +} + +// MouseMoved must be called for a mouse move. +func (display *MapDisplay) MouseMoved(mouseX, mouseY float32) { + display.updateMouseWorldPosition(mouseX, mouseY) + if display.positionValid { + display.hoverItems.find(display.editor.Level(), display.position) + } else { + display.hoverItems.reset() + } + display.moveCapture(mouseX, mouseY) +} + +// MouseScrolled must be called for a mouse scroll. +func (display *MapDisplay) MouseScrolled(mouseX, mouseY float32, deltaX, deltaY float32, modifier input.Modifier) { + if modifier.Has(input.ModControl) { + display.hoverItems.scroll(deltaY > 0) + } else { + worldX, worldY := display.unprojectPixel(mouseX, mouseY) + + if deltaY < 0 { + display.camera.ZoomAt(-0.5, worldX, worldY) + } + if deltaY > 0 { + display.camera.ZoomAt(0.5, worldX, worldY) + } + } +} + +func (display *MapDisplay) updateMouseWorldPosition(mouseX, mouseY float32) { + worldX, worldY := display.unprojectPixel(mouseX, mouseY) + lvl := display.editor.Level() + width, height, _ := lvl.Size() + worldWidth := float32(width) * level.FineCoordinatesPerTileSide + worldHeight := float32(height) * level.FineCoordinatesPerTileSide + + display.positionValid = (worldX >= 0.0) && (worldX < worldWidth) && (worldY >= 0.0) && (worldY < worldHeight) + if display.positionValid { + display.position = MapPosition{X: level.Coordinate(worldX + 0.5), Y: level.Coordinate(worldY + 0.5)} + } else { + display.position = MapPosition{} + } +} diff --git a/modding/hacked/editor/levels/MapGrid.go b/modding/hacked/editor/levels/MapGrid.go new file mode 100644 index 0000000..1f316b7 --- /dev/null +++ b/modding/hacked/editor/levels/MapGrid.go @@ -0,0 +1,312 @@ +package levels + +import ( + mgl "github.com/go-gl/mathgl/mgl32" + + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var mapTileGridVertexShaderSource = ` +#version 150 +precision mediump float; + +in vec4 vertexPosition; +out float colorAlpha; + +uniform mat4 alphaMatrix; + +uniform mat4 modelMatrix; +uniform mat4 viewMatrix; +uniform mat4 projectionMatrix; + +void main(void) { + gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition.xy, 0.0, 1.0); + colorAlpha = alphaMatrix[int(vertexPosition.z)][int(vertexPosition.w)]; +} +` + +var mapTileGridFragmentShaderSource = ` +#version 150 +precision mediump float; + +uniform vec4 color; +in float colorAlpha; +out vec4 fragColor; + +void main(void) { + fragColor = vec4(color.rgb, color.a*colorAlpha); +} +` + +// TileMapper returns basic information to draw a 2D map. +type TileMapper interface { + MapGridInfo(pos level.TilePosition) (level.TileType, level.TileSlopeControl, level.WallHeights) +} + +// MapGrid renders the grid of the map, based on calculated wall heights. +type MapGrid struct { + context *render.Context + + program uint32 + vao *opengl.VertexArrayObject + vertexPositionBuffer uint32 + tickVertexPositionBuffer uint32 + vertexPositionAttrib int32 + colorUniform opengl.Vector4Uniform + + /* + The alpha matrix is set up to specify the alpha value used for the wall sections. + There are four cardinal walls with three sections each, plus two diagonal walls (single section), as well + as the slope-ticks that re-use the shader. + The matrix is meant to be used like this: + N N N D1 + E E E D2 + S S S - + W W W T + D1 and D2 being the two diagonals, and T specifying the cell for the tick-marks. + The vertex buffers are an array of v4 entries, with z/w specifying the row and column index to take. + */ + alphaMatrixUniform opengl.Matrix4Uniform + modelMatrixUniform opengl.Matrix4Uniform + viewMatrixUniform opengl.Matrix4Uniform + projectionMatrixUniform opengl.Matrix4Uniform + + slopeTicksByType [][]int + floorTickStarts []int32 + ceilingTickStarts []int32 +} + +// NewMapGrid returns a new instance. +func NewMapGrid(context *render.Context) *MapGrid { + gl := context.OpenGL + program, programErr := opengl.LinkNewStandardProgram(gl, mapTileGridVertexShaderSource, mapTileGridFragmentShaderSource) + if programErr != nil { + panic(opengl.NamedShaderError{Name: "MapGrid", Nested: programErr}) + } + grid := &MapGrid{ + context: context, + program: program, + vao: opengl.NewVertexArrayObject(gl, program), + vertexPositionBuffer: gl.GenBuffers(1)[0], + tickVertexPositionBuffer: gl.GenBuffers(1)[0], + vertexPositionAttrib: gl.GetAttribLocation(program, "vertexPosition"), + colorUniform: opengl.Vector4Uniform(gl.GetUniformLocation(program, "color")), + alphaMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "alphaMatrix")), + modelMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "modelMatrix")), + viewMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "viewMatrix")), + projectionMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "projectionMatrix")), + } + + { + tickVertices := grid.calculateTickVertices() + gl.BindBuffer(opengl.ARRAY_BUFFER, grid.tickVertexPositionBuffer) + gl.BufferData(opengl.ARRAY_BUFFER, len(tickVertices)*4, tickVertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + { + const parts = 3 + left := float32(-0.5) + right := float32(0.5) + top := float32(0.5) + bottom := float32(-0.5) + finePerPart := float32(1.0 / parts) + + var wallVertices []float32 + for i := 0; i < parts; i++ { + wallVertices = append(wallVertices, left+(finePerPart*float32(i)), top, 0.0, float32(i)) + wallVertices = append(wallVertices, left+(finePerPart*float32(i+1)), top, 0.0, float32(i)) + } + for i := 0; i < parts; i++ { + wallVertices = append(wallVertices, right, top-(finePerPart*float32(i)), 1.0, float32(i)) + wallVertices = append(wallVertices, right, top-(finePerPart*float32(i+1)), 1.0, float32(i)) + } + for i := 0; i < parts; i++ { + wallVertices = append(wallVertices, right-(finePerPart*float32(i)), bottom, 2.0, float32(i)) + wallVertices = append(wallVertices, right-(finePerPart*float32(i+1)), bottom, 2.0, float32(i)) + } + for i := 0; i < parts; i++ { + wallVertices = append(wallVertices, left, bottom+(finePerPart*float32(i)), 3.0, float32(i)) + wallVertices = append(wallVertices, left, bottom+(finePerPart*float32(i+1)), 3.0, float32(i)) + } + wallVertices = append(wallVertices, left, top, 0.0, 3.0) + wallVertices = append(wallVertices, right, bottom, 0.0, 3.0) + wallVertices = append(wallVertices, right, top, 1.0, 3.0) + wallVertices = append(wallVertices, left, bottom, 1.0, 3.0) + + gl.BindBuffer(opengl.ARRAY_BUFFER, grid.vertexPositionBuffer) + gl.BufferData(opengl.ARRAY_BUFFER, len(wallVertices)*4, wallVertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + { + grid.slopeTicksByType = [][]int{ + nil, + nil, + + nil, + nil, + nil, + nil, + + {0, 1}, + {1, 2}, + {2, 3}, + {3, 0}, + + {3, 0, 1}, + {0, 1, 2}, + {1, 2, 3}, + {2, 3, 0}, + + {2}, + {3}, + {0}, + {1}, + } + grid.floorTickStarts = []int32{0, 6, 12, 18} + grid.ceilingTickStarts = []int32{3, 9, 15, 21} + } + + return grid +} + +// Dispose releases any internal resources. +func (grid *MapGrid) Dispose() { + gl := grid.context.OpenGL + gl.DeleteProgram(grid.program) + gl.DeleteBuffers([]uint32{grid.vertexPositionBuffer, grid.tickVertexPositionBuffer}) + grid.vao.Dispose() +} + +func (grid *MapGrid) calculateTickVertices() []float32 { + dotHalf := float32(0.05) + dotBase := float32(0.5) - (dotHalf * 2.0) + + top := dotBase + dotHalf + topEnd := dotBase - dotHalf + left := -dotBase - dotHalf + leftEnd := -dotBase + dotHalf + + right := top + rightEnd := topEnd + bottom := left + bottomEnd := leftEnd + + return []float32{ + left, top, 3.0, 3.0, leftEnd, top, 3.0, 3.0, left, topEnd, 3.0, 3.0, + leftEnd, top, 3.0, 3.0, leftEnd, topEnd, 3.0, 3.0, left, topEnd, 3.0, 3.0, + + right, top, 3.0, 3.0, right, topEnd, 3.0, 3.0, rightEnd, top, 3.0, 3.0, + rightEnd, top, 3.0, 3.0, right, topEnd, 3.0, 3.0, rightEnd, topEnd, 3.0, 3.0, + + right, bottom, 3.0, 3.0, rightEnd, bottom, 3.0, 3.0, right, bottomEnd, 3.0, 3.0, + right, bottomEnd, 3.0, 3.0, rightEnd, bottom, 3.0, 3.0, rightEnd, bottomEnd, 3.0, 3.0, + + left, bottomEnd, 3.0, 3.0, leftEnd, bottom, 3.0, 3.0, left, bottom, 3.0, 3.0, + left, bottomEnd, 3.0, 3.0, leftEnd, bottomEnd, 3.0, 3.0, leftEnd, bottom, 3.0, 3.0, + } +} + +// Render renders the grid. +func (grid *MapGrid) Render(columns, rows int, mapper TileMapper) { + gl := grid.context.OpenGL + + grid.vao.OnShader(func() { + modelMatrix := mgl.Ident4() + lineColor := [4]float32{0.0, 0.8, 0.0, 1.0} + + grid.viewMatrixUniform.Set(gl, grid.context.ViewMatrix) + grid.projectionMatrixUniform.Set(gl, &grid.context.ProjectionMatrix) + + heightFactor := [32*2 + 1]float32{ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, + 0.1, 0.2, 0.3, 0.3, 0.4, 0.4, 0.5, 0.7, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + } + + for y := 0; y < rows; y++ { + for x := 0; x < columns; x++ { + tileType, slopeControl, wallHeights := mapper.MapGridInfo(level.TilePosition{X: byte(x), Y: byte(y)}) + if tileType == level.TileTypeSolid { + continue + } + + modelMatrix = mgl.Ident4(). + Mul4(mgl.Translate3D((float32(x)+0.5)*level.FineCoordinatesPerTileSide, (float32(y)+0.5)*level.FineCoordinatesPerTileSide, 0.0)). + Mul4(mgl.Scale3D(level.FineCoordinatesPerTileSide, level.FineCoordinatesPerTileSide, 1.0)) + grid.modelMatrixUniform.Set(gl, &modelMatrix) + var alphaMatrix mgl.Mat4 + alphaMatrix[15] = 1.0 // for tick marks + + gl.EnableVertexAttribArray(uint32(grid.vertexPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, grid.vertexPositionBuffer) + gl.VertexAttribOffset(uint32(grid.vertexPositionAttrib), 4, opengl.FLOAT, false, 0, 0) + + alphaMatrix[0+0] = heightFactor[int(wallHeights.North[0])+32] + alphaMatrix[0+1] = heightFactor[int(wallHeights.North[1])+32] + alphaMatrix[0+2] = heightFactor[int(wallHeights.North[2])+32] + + alphaMatrix[4+0] = heightFactor[int(wallHeights.East[0])+32] + alphaMatrix[4+1] = heightFactor[int(wallHeights.East[1])+32] + alphaMatrix[4+2] = heightFactor[int(wallHeights.East[2])+32] + + alphaMatrix[8+0] = heightFactor[int(wallHeights.South[0])+32] + alphaMatrix[8+1] = heightFactor[int(wallHeights.South[1])+32] + alphaMatrix[8+2] = heightFactor[int(wallHeights.South[2])+32] + + alphaMatrix[12+0] = heightFactor[int(wallHeights.West[0])+32] + alphaMatrix[12+1] = heightFactor[int(wallHeights.West[1])+32] + alphaMatrix[12+2] = heightFactor[int(wallHeights.West[2])+32] + if tileType == level.TileTypeDiagonalOpenNorthEast || tileType == level.TileTypeDiagonalOpenSouthWest { + alphaMatrix[0+3] = 1.0 + } + if tileType == level.TileTypeDiagonalOpenNorthWest || tileType == level.TileTypeDiagonalOpenSouthEast { + alphaMatrix[4+3] = 1.0 + } + + grid.colorUniform.Set(gl, &lineColor) + grid.alphaMatrixUniform.Set(gl, &alphaMatrix) + gl.DrawArrays(opengl.LINES, 0, 28) + + grid.renderSlopeTicks(gl, tileType, slopeControl) + } + } + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) +} + +func (grid *MapGrid) renderSlopeTicks(gl opengl.OpenGL, tileType level.TileType, slopeControl level.TileSlopeControl) { + var floorTicks []int + var ceilingTicks []int + + if slopeControl != level.TileSlopeControlFloorFlat { + floorTicks = grid.slopeTicksByType[tileType] + } + if slopeControl == level.TileSlopeControlCeilingMirrored { + ceilingTicks = grid.slopeTicksByType[tileType] + } else if slopeControl != level.TileSlopeControlCeilingFlat { + ceilingTicks = grid.slopeTicksByType[tileType.Info().SlopeInvertedType] + } + + gl.EnableVertexAttribArray(uint32(grid.vertexPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, grid.tickVertexPositionBuffer) + gl.VertexAttribOffset(uint32(grid.vertexPositionAttrib), 4, opengl.FLOAT, false, 0, 0) + + floorTickColor := [4]float32{0.0, 0.8, 0.0, 0.9} + grid.colorUniform.Set(gl, &floorTickColor) + for _, index := range floorTicks { + gl.DrawArrays(opengl.TRIANGLES, grid.floorTickStarts[index], 3) + } + ceilingTickColor := [4]float32{0.8, 0.0, 0.0, 0.9} + grid.colorUniform.Set(gl, &ceilingTickColor) + for _, index := range ceilingTicks { + gl.DrawArrays(opengl.TRIANGLES, grid.ceilingTickStarts[index], 3) + } +} diff --git a/modding/hacked/editor/levels/MapIcons.go b/modding/hacked/editor/levels/MapIcons.go new file mode 100644 index 0000000..c103529 --- /dev/null +++ b/modding/hacked/editor/levels/MapIcons.go @@ -0,0 +1,198 @@ +package levels + +import ( + mgl "github.com/go-gl/mathgl/mgl32" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var mapIconsVertexShaderSource = ` +#version 150 +precision mediump float; + +in vec3 vertexPosition; +in vec3 uvPosition; + +uniform mat4 modelMatrix; +uniform mat4 viewMatrix; +uniform mat4 projectionMatrix; +uniform vec4 uvLimit; + +out vec2 uv; + +void main(void) { + gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition, 1.0); + + uv = vec2(uvPosition.x * uvLimit.x, uvPosition.y * uvLimit.y); +} +` + +var mapIconsFragmentShaderSource = ` +#version 150 +precision mediump float; + +uniform sampler2D palette; +uniform sampler2D bitmap; + +in vec2 uv; +out vec4 fragColor; + +void main(void) { + vec4 pixel = texture(bitmap, uv); + + if (pixel.r > 0.0) { + fragColor = texture(palette, vec2(pixel.r, 0.5)); + } else { + discard; + } +} +` + +// MapIcons is a renderable for simple bitmaps. +type MapIcons struct { + context *render.Context + + program uint32 + vao *opengl.VertexArrayObject + vertexPositionBuffer uint32 + vertexPositionAttrib int32 + uvPositionBuffer uint32 + uvPositionAttrib int32 + modelMatrixUniform opengl.Matrix4Uniform + viewMatrixUniform opengl.Matrix4Uniform + projectionMatrixUniform opengl.Matrix4Uniform + uvLimitUniform opengl.Vector4Uniform + + paletteUniform int32 + bitmapUniform int32 +} + +type iconData struct { + pos MapPosition + texture *graphics.BitmapTexture +} + +// NewMapIcons returns a new instance. +func NewMapIcons(context *render.Context) *MapIcons { + gl := context.OpenGL + program, programErr := opengl.LinkNewStandardProgram(gl, mapIconsVertexShaderSource, mapIconsFragmentShaderSource) + + if programErr != nil { + panic(opengl.NamedShaderError{Name: "MapIconsShader", Nested: programErr}) + } + renderable := &MapIcons{ + context: context, + program: program, + + vao: opengl.NewVertexArrayObject(gl, program), + vertexPositionBuffer: gl.GenBuffers(1)[0], + vertexPositionAttrib: gl.GetAttribLocation(program, "vertexPosition"), + uvPositionBuffer: gl.GenBuffers(1)[0], + uvPositionAttrib: gl.GetAttribLocation(program, "uvPosition"), + modelMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "modelMatrix")), + viewMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "viewMatrix")), + projectionMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "projectionMatrix")), + uvLimitUniform: opengl.Vector4Uniform(gl.GetUniformLocation(program, "uvLimit")), + + paletteUniform: gl.GetUniformLocation(program, "palette"), + bitmapUniform: gl.GetUniformLocation(program, "bitmap")} + + { + half := float32(0.5) + var vertices = []float32{ + -half, half, 0.0, + half, half, 0.0, + half, -half, 0.0, + + half, -half, 0.0, + -half, -half, 0.0, + -half, half, 0.0, + } + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexPositionBuffer) + gl.BufferData(opengl.ARRAY_BUFFER, len(vertices)*4, vertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + { + var uv = []float32{ + 0.0, 0.0, 0.0, + 1.0, 0.0, 0.0, + 1.0, 1.0, 0.0, + + 1.0, 1.0, 0.0, + 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, + } + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.uvPositionBuffer) + gl.BufferData(opengl.ARRAY_BUFFER, len(uv)*4, uv, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + renderable.vao.WithSetter(func(gl opengl.OpenGL) { + gl.EnableVertexAttribArray(uint32(renderable.vertexPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexPositionBuffer) + gl.VertexAttribOffset(uint32(renderable.vertexPositionAttrib), 3, opengl.FLOAT, false, 0, 0) + gl.EnableVertexAttribArray(uint32(renderable.uvPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.uvPositionBuffer) + gl.VertexAttribOffset(uint32(renderable.uvPositionAttrib), 3, opengl.FLOAT, false, 0, 0) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) + + return renderable +} + +// Render renders the icons with their center at given position. +func (renderable *MapIcons) Render(paletteTexture *graphics.PaletteTexture, iconSize float32, icons []iconData) { + gl := renderable.context.OpenGL + + renderable.vao.OnShader(func() { + renderable.viewMatrixUniform.Set(gl, renderable.context.ViewMatrix) + renderable.projectionMatrixUniform.Set(gl, &renderable.context.ProjectionMatrix) + + textureUnit := int32(0) + gl.ActiveTexture(opengl.TEXTURE0 + uint32(textureUnit)) + gl.BindTexture(opengl.TEXTURE_2D, paletteTexture.Handle()) + gl.Uniform1i(renderable.paletteUniform, textureUnit) + + textureUnit = 1 + gl.ActiveTexture(opengl.TEXTURE0 + uint32(textureUnit)) + gl.Uniform1i(renderable.bitmapUniform, textureUnit) + for _, icon := range icons { + x, y := float32(icon.pos.X), float32(icon.pos.Y) + u, v := icon.texture.UV() + width, height := renderable.limitedSize(iconSize, icon.texture) + modelMatrix := mgl.Ident4(). + Mul4(mgl.Translate3D(x, y, 0.0)). + Mul4(mgl.Scale3D(width, height, 1.0)) + + renderable.modelMatrixUniform.Set(gl, &modelMatrix) + + uvLimit := [4]float32{u, v, 0, 0} + renderable.uvLimitUniform.Set(gl, &uvLimit) + + gl.BindTexture(opengl.TEXTURE_2D, icon.texture.Handle()) + gl.DrawArrays(opengl.TRIANGLES, 0, 6) + } + gl.BindTexture(opengl.TEXTURE_2D, 0) + }) +} + +func (renderable *MapIcons) limitedSize(iconSize float32, texture *graphics.BitmapTexture) (width, height float32) { + width, height = texture.Size() + referenceSize := float32(16.0) + larger := width + + if larger < height { + larger = height + } + if larger > referenceSize { + ratio := referenceSize / larger + width *= ratio + height *= ratio + } + factor := iconSize / referenceSize + width *= factor + height *= factor + + return +} diff --git a/modding/hacked/editor/levels/MapPosition.go b/modding/hacked/editor/levels/MapPosition.go new file mode 100644 index 0000000..cc0d49d --- /dev/null +++ b/modding/hacked/editor/levels/MapPosition.go @@ -0,0 +1,19 @@ +package levels + +import "github.com/inkyblackness/hacked/ss1/content/archive/level" + +// MapPosition describes a specific two-dimensional point on the map. +type MapPosition struct { + X level.Coordinate + Y level.Coordinate +} + +// Tile returns the tile position of the exact position. +func (pos MapPosition) Tile() level.TilePosition { + return level.TilePosition{X: pos.X.Tile(), Y: pos.Y.Tile()} +} + +// Fine returns the fine position of the exact position. +func (pos MapPosition) Fine() level.FinePosition { + return level.FinePosition{X: pos.X.Fine(), Y: pos.Y.Fine()} +} diff --git a/modding/hacked/editor/levels/MapTextures.go b/modding/hacked/editor/levels/MapTextures.go new file mode 100644 index 0000000..a5654dc --- /dev/null +++ b/modding/hacked/editor/levels/MapTextures.go @@ -0,0 +1,255 @@ +package levels + +import ( + "math" + + mgl "github.com/go-gl/mathgl/mgl32" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var mapTexturesVertexShaderSource = ` +#version 150 +precision mediump float; + +in vec3 vertexPosition; + +uniform mat4 modelMatrix; +uniform mat4 viewMatrix; +uniform mat4 projectionMatrix; +uniform mat4 uvMatrix; + +out vec2 uv; + +void main(void) { + gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition, 1.0); + + uv = (uvMatrix * vec4(vertexPosition, 1.0)).xy; +} +` + +var mapTexturesFragmentShaderSource = ` +#version 150 +precision mediump float; + +in vec2 uv; + +uniform sampler2D palette; +uniform sampler2D bitmap; + +out vec4 fragColor; + +void main(void) { + vec4 pixel = texture(bitmap, uv); + + fragColor = texture(palette, vec2(pixel.r, 0.5)); +} +` + +// TextureQuery is a getter function to retrieve the texture for the given +// level texture index. +type TextureQuery func(index level.TextureIndex) (*graphics.BitmapTexture, error) + +// TileTextureQuery is a getter function to retrieve properties for rendering a texture of a tile. +type TileTextureQuery func(pos level.TilePosition) (tileType level.TileType, textureIndex level.TextureIndex, textureRotations int) + +// MapTextures is a renderable for textures. +type MapTextures struct { + context *render.Context + + program uint32 + vao *opengl.VertexArrayObject + vertexPositionBuffer uint32 + vertexPositionAttrib int32 + modelMatrixUniform opengl.Matrix4Uniform + viewMatrixUniform opengl.Matrix4Uniform + projectionMatrixUniform opengl.Matrix4Uniform + uvMatrixUniform opengl.Matrix4Uniform + + paletteUniform int32 + bitmapUniform int32 + + textureQuery TextureQuery + + lastTileType level.TileType +} + +type tileTypeVertices struct { + vertices []float32 + equivalentType level.TileType +} + +var tileTypeVerticesLookup []tileTypeVertices +var uvRotations [4]*mgl.Mat4 + +func init() { + for i := 0; i < 4; i++ { + matrix := mgl.Translate3D(0.5, 0.5, 0.0). + Mul4(mgl.HomogRotate3DZ(math.Pi * float32(i) / -2.0)). + Mul4(mgl.Translate3D(-0.5, -0.5, 0.0)). + Mul4(mgl.Scale3D(1.0, -1.0, 1.0)) + uvRotations[i] = &matrix + } + + for _, tileType := range level.TileTypes() { + for int(tileType) >= len(tileTypeVerticesLookup) { + tileTypeVerticesLookup = append(tileTypeVerticesLookup, tileTypeVertices{equivalentType: level.TileTypeSolid}) + } + switch tileType { + case level.TileTypeSolid: + case level.TileTypeDiagonalOpenNorthEast: + tileTypeVerticesLookup[int(tileType)] = tileTypeVertices{ + vertices: []float32{ + 0.0, 1.0, 0.0, + 1.0, 1.0, 0.0, + 1.0, 0.0, 0.0, + }, + equivalentType: level.TileTypeDiagonalOpenNorthEast, + } + case level.TileTypeDiagonalOpenNorthWest: + tileTypeVerticesLookup[int(tileType)] = tileTypeVertices{ + vertices: []float32{ + 0.0, 1.0, 0.0, + 1.0, 1.0, 0.0, + 0.0, 0.0, 0.0, + }, + equivalentType: level.TileTypeDiagonalOpenNorthWest, + } + case level.TileTypeDiagonalOpenSouthEast: + tileTypeVerticesLookup[int(tileType)] = tileTypeVertices{ + vertices: []float32{ + 1.0, 1.0, 0.0, + 1.0, 0.0, 0.0, + 0.0, 0.0, 0.0, + }, + equivalentType: level.TileTypeDiagonalOpenSouthEast, + } + case level.TileTypeDiagonalOpenSouthWest: + tileTypeVerticesLookup[int(tileType)] = tileTypeVertices{ + vertices: []float32{ + 0.0, 1.0, 0.0, + 1.0, 0.0, 0.0, + 0.0, 0.0, 0.0, + }, + equivalentType: level.TileTypeDiagonalOpenSouthWest, + } + default: + tileTypeVerticesLookup[int(tileType)] = tileTypeVertices{ + vertices: []float32{ + 0.0, 0.0, 0.0, + 1.0, 0.0, 0.0, + 1.0, 1.0, 0.0, + + 1.0, 1.0, 0.0, + 0.0, 1.0, 0.0, + 0.0, 0.0, 0.0, + }, + equivalentType: level.TileTypeOpen, + } + } + } +} + +// NewMapTextures returns a new instance of a renderable for tile map textures. +func NewMapTextures(context *render.Context, textureQuery TextureQuery) *MapTextures { + gl := context.OpenGL + program, programErr := opengl.LinkNewStandardProgram(gl, mapTexturesVertexShaderSource, mapTexturesFragmentShaderSource) + if programErr != nil { + panic(opengl.NamedShaderError{Name: "MapTexturesShader", Nested: programErr}) + } + renderable := &MapTextures{ + context: context, + program: program, + + vao: opengl.NewVertexArrayObject(gl, program), + vertexPositionBuffer: gl.GenBuffers(1)[0], + vertexPositionAttrib: gl.GetAttribLocation(program, "vertexPosition"), + modelMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "modelMatrix")), + viewMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "viewMatrix")), + projectionMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "projectionMatrix")), + uvMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "uvMatrix")), + paletteUniform: gl.GetUniformLocation(program, "palette"), + bitmapUniform: gl.GetUniformLocation(program, "bitmap"), + textureQuery: textureQuery, + lastTileType: level.TileTypeSolid, + } + + renderable.vao.WithSetter(func(gl opengl.OpenGL) { + gl.EnableVertexAttribArray(uint32(renderable.vertexPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexPositionBuffer) + gl.VertexAttribOffset(uint32(renderable.vertexPositionAttrib), 3, opengl.FLOAT, false, 0, 0) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) + + return renderable +} + +// Dispose releases any internal resources. +func (renderable *MapTextures) Dispose() { + gl := renderable.context.OpenGL + + renderable.vao.Dispose() + gl.DeleteProgram(renderable.program) + gl.DeleteBuffers([]uint32{renderable.vertexPositionBuffer}) +} + +// Render renders the textures. +func (renderable *MapTextures) Render(columns, rows int, tileTextureQuery TileTextureQuery, paletteTexture *graphics.PaletteTexture) { + gl := renderable.context.OpenGL + + renderable.vao.OnShader(func() { + renderable.viewMatrixUniform.Set(gl, renderable.context.ViewMatrix) + renderable.projectionMatrixUniform.Set(gl, &renderable.context.ProjectionMatrix) + + textureUnit := int32(0) + gl.ActiveTexture(opengl.TEXTURE0 + uint32(textureUnit)) + gl.BindTexture(opengl.TEXTURE_2D, paletteTexture.Handle()) + gl.Uniform1i(renderable.paletteUniform, textureUnit) + + textureUnit = 1 + gl.ActiveTexture(opengl.TEXTURE0 + uint32(textureUnit)) + gl.Uniform1i(renderable.bitmapUniform, textureUnit) + + scaling := mgl.Scale3D(level.FineCoordinatesPerTileSide, level.FineCoordinatesPerTileSide, 1.0) + for y := 0; y < rows; y++ { + for x := 0; x < columns; x++ { + tileType, textureIndex, textureRotations := tileTextureQuery(level.TilePosition{X: byte(x), Y: byte(y)}) + if tileType == level.TileTypeSolid { + continue + } + texture, _ := renderable.textureQuery(textureIndex) + if texture == nil { + continue + } + + modelMatrix := mgl.Translate3D(float32(x)*level.FineCoordinatesPerTileSide, float32(y)*level.FineCoordinatesPerTileSide, 0.0). + Mul4(scaling) + + uvMatrix := uvRotations[textureRotations] + renderable.uvMatrixUniform.Set(gl, uvMatrix) + renderable.modelMatrixUniform.Set(gl, &modelMatrix) + gl.BindTexture(opengl.TEXTURE_2D, texture.Handle()) + renderable.renderTileType(tileType) + } + } + + gl.BindTexture(opengl.TEXTURE_2D, 0) + }) +} + +func (renderable *MapTextures) renderTileType(tileType level.TileType) { + info := tileTypeVerticesLookup[int(tileType)] + vertexCount := len(info.vertices) / 3 + gl := renderable.context.OpenGL + if info.equivalentType != renderable.lastTileType { + renderable.lastTileType = info.equivalentType + + gl.BindBuffer(opengl.ARRAY_BUFFER, renderable.vertexPositionBuffer) + gl.BufferData(opengl.ARRAY_BUFFER, len(info.vertices)*4, info.vertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + gl.DrawArrays(opengl.TRIANGLES, 0, int32(vertexCount)) +} diff --git a/modding/hacked/editor/levels/ObjectsView.go b/modding/hacked/editor/levels/ObjectsView.go new file mode 100644 index 0000000..e6ed175 --- /dev/null +++ b/modding/hacked/editor/levels/ObjectsView.go @@ -0,0 +1,1020 @@ +package levels + +import ( + "fmt" + "math" + "strings" + + mgl "github.com/go-gl/mathgl/mgl32" + "github.com/inkyblackness/hacked/ui/gui" + "github.com/inkyblackness/hacked/ui/opengl" + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/editor/values" + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/numbers" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +const shouldRenderRotationView = false + +// ObjectsView is for object properties. +type ObjectsView struct { + gameObjects *edit.GameObjectsService + levelSelection *edit.LevelSelectionService + editor *edit.LevelEditorService + varInfoProvider archive.GameVariableInfoProvider + textCache *text.Cache + textureCache *graphics.TextureCache + + guiScale float32 + registry cmd.Registry + + textureRenderer *render.TextureRenderer + orientationView *render.OrientationView + + model objectsViewModel +} + +// NewObjectsView returns a new instance. +func NewObjectsView(gameObjects *edit.GameObjectsService, + editor *edit.LevelEditorService, + levelSelection *edit.LevelSelectionService, + varInfoProvider archive.GameVariableInfoProvider, + guiScale float32, textCache *text.Cache, textureCache *graphics.TextureCache, + registry cmd.Registry, gl opengl.OpenGL) *ObjectsView { + textureRenderer := render.NewTextureRenderer(gl) + viewMatrix := mgl.LookAt(0.35, 0.35, -1, 0, 0, 0, 1.0, 0.0, 0.0) + context := render.Context{ + OpenGL: gl, + ViewMatrix: &viewMatrix, + ProjectionMatrix: mgl.Ortho(-0.5, 0.5, 0.5, -0.5, -10, 10.0), + } + // Set up an orientation view. The orientation parameter would match the + // map as seen from above, and the rotation to match how the objects rotate in world. + orientationView := render.NewOrientationView(context, + mgl.Ident4(). + Mul4(mgl.HomogRotate3D(math.Pi/2, mgl.Vec3{0.0, 0.0, 1.0})). + Mul4(mgl.HomogRotate3D(math.Pi, mgl.Vec3{1.0, 0.0, 0.0})), + mgl.Vec3{1.0, 1.0, -1.0}) + + view := &ObjectsView{ + gameObjects: gameObjects, + levelSelection: levelSelection, + editor: editor, + varInfoProvider: varInfoProvider, + textCache: textCache, + textureCache: textureCache, + + guiScale: guiScale, + registry: registry, + + textureRenderer: textureRenderer, + orientationView: orientationView, + + model: freshObjectsViewModel(), + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *ObjectsView) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render renders the view. +func (view *ObjectsView) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + lvl := view.editor.Level() + objects := view.editor.Objects() + imgui.SetNextWindowSizeV(imgui.Vec2{X: 400 * view.guiScale, Y: 500 * view.guiScale}, imgui.ConditionFirstUseEver) + title := fmt.Sprintf("Level Objects, %d selected", len(objects)) + readOnly := view.editor.IsReadOnly() + if readOnly { + title += hintReadOnly + } + if imgui.BeginV(title+"###Level Objects", view.WindowOpen(), imgui.WindowFlagsHorizontalScrollbar|imgui.WindowFlagsAlwaysVerticalScrollbar) { + view.renderContent(lvl, objects, readOnly) + } + imgui.End() + } +} + +func (view *ObjectsView) renderContent(lvl *level.Level, objects []*level.ObjectMainEntry, readOnly bool) { + classUnifier := values.NewUnifier() + typeUnifier := values.NewUnifier() + zUnifier := values.NewUnifier() + tileXUnifier := values.NewUnifier() + fineXUnifier := values.NewUnifier() + tileYUnifier := values.NewUnifier() + fineYUnifier := values.NewUnifier() + rotationXUnifier := values.NewUnifier() + rotationYUnifier := values.NewUnifier() + rotationZUnifier := values.NewUnifier() + hitpointsUnifier := values.NewUnifier() + + for _, obj := range objects { + classUnifier.Add(object.TripleFrom(int(obj.Class), 0, 0)) + typeUnifier.Add(obj.Triple()) + zUnifier.Add(obj.Z) + tileXUnifier.Add(obj.X.Tile()) + fineXUnifier.Add(obj.X.Fine()) + tileYUnifier.Add(obj.Y.Tile()) + fineYUnifier.Add(obj.Y.Fine()) + rotationXUnifier.Add(int32(obj.XRotation)) + rotationYUnifier.Add(int32(obj.YRotation)) + rotationZUnifier.Add(int32(obj.ZRotation)) + hitpointsUnifier.Add(obj.Hitpoints) + } + + imgui.PushItemWidth(-150 * view.guiScale) + columns, rows, levelHeight := lvl.Size() + + objectHeightFormatter := objectHeightFormatterFor(levelHeight) + + if !readOnly { + classString := func(class object.Class) string { + active, capacity := lvl.ObjectClassStats(class) + if class == object.ClassSmallStuff { + // The engine backs up inventory items in the level during a level transition. + // For this it requires space. If there is none, bugs do appear. + // This limit could apply for several classes, yet only small stuff is being used. + practicalLimit := 0 + if capacity > level.InventorySize { + practicalLimit = capacity - level.InventorySize + } + return fmt.Sprintf("%2d: %v -- %d/%d (%d)", int(class), class, active, practicalLimit, capacity) + } + return fmt.Sprintf("%2d: %v -- %d/%d", int(class), class, active, capacity) + } + if imgui.BeginCombo("New Object Class", classString(view.editor.NewObjectTriple().Class)) { + for _, class := range object.Classes() { + if imgui.SelectableV(classString(class), class == view.editor.NewObjectTriple().Class, 0, imgui.Vec2{}) { + view.editor.SetNewObjectTriple(object.TripleFrom(int(class), 0, 0)) + } + } + imgui.EndCombo() + } + if imgui.BeginCombo("New Object Type", view.tripleName(view.editor.NewObjectTriple())) { + allTypes := view.gameObjects.AllProperties().TriplesInClass(view.editor.NewObjectTriple().Class) + bitmapInfo := view.gameObjects.BitmapInfo() + lineHeight := imgui.TextLineHeight() + iconSize := imgui.Vec2{X: lineHeight * view.guiScale, Y: lineHeight * view.guiScale} + for index, triple := range allTypes { + info := bitmapInfo[triple] + iconKey := resource.KeyOf(ids.ObjectBitmaps, resource.LangAny, info.Start+info.IconRecommendation) + imgui.PushIDInt(index) + imgui.BeginGroup() + render.TextureImage("Icon", view.textureCache, iconKey, iconSize) + imgui.SameLine() + if imgui.SelectableV(view.tripleName(triple), triple == view.editor.NewObjectTriple(), 0, imgui.Vec2{}) { + view.editor.SetNewObjectTriple(triple) + } + imgui.EndGroup() + imgui.PopID() + } + imgui.EndCombo() + } + if imgui.Button("Delete Selected") { + view.requestDeleteObjects() + } + imgui.Separator() + } + + selectedIDs := view.levelSelection.CurrentSelectedObjects() + switch { + case len(selectedIDs) == 1: + imgui.LabelText("ID", fmt.Sprintf("%3d", int(selectedIDs[0]))) + case len(selectedIDs) > 1: + imgui.LabelText("ID", "(multiple)") + default: + imgui.LabelText("ID", "") + } + + objTypeRenderer := values.ObjectTypeControlRenderer{ + Meta: view.gameObjects.AllProperties(), + TextCache: view.textCache, + } + objTypeRenderer.Render(readOnly, "Object Type", classUnifier, typeUnifier, + func(u values.Unifier) object.Triple { return u.Unified().(object.Triple) }, + func(newValue object.Triple) { + view.requestChangeObjects("ChangeBaseType", func(entry *level.ObjectMainEntry) { + entry.Subclass = newValue.Subclass + entry.Type = newValue.Type + }) + }) + + if imgui.TreeNodeV("Base Properties", imgui.TreeNodeFlagsDefaultOpen|imgui.TreeNodeFlagsFramed) { + values.RenderUnifiedSliderInt(readOnly, "Z", zUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.HeightUnit)) }, + objectHeightFormatter, + 0, 0xFF, + func(newValue int) { + view.requestChangeObjects("ChangeBaseZ", func(entry *level.ObjectMainEntry) { entry.Z = level.HeightUnit(newValue) }) + }) + values.RenderUnifiedSliderInt(readOnly, "Tile X", tileXUnifier, + func(u values.Unifier) int { return int(u.Unified().(byte)) }, + func(value int) string { return "%d" }, + 0, columns-1, + func(newValue int) { + view.requestChangeObjects("ChangeBaseTileX", func(entry *level.ObjectMainEntry) { entry.X = level.CoordinateAt(byte(newValue), entry.X.Fine()) }) + }) + values.RenderUnifiedSliderInt(readOnly, "Fine X", fineXUnifier, + func(u values.Unifier) int { return int(u.Unified().(byte)) }, + func(value int) string { return "%d" }, + 0, level.FineCoordinatesPerTileSide-1, + func(newValue int) { + view.requestChangeObjects("ChangeBaseFineX", func(entry *level.ObjectMainEntry) { entry.X = level.CoordinateAt(entry.X.Tile(), byte(newValue)) }) + }) + values.RenderUnifiedSliderInt(readOnly, "Tile Y", tileYUnifier, + func(u values.Unifier) int { return int(u.Unified().(byte)) }, + func(value int) string { return "%d" }, + 0, rows-1, + func(newValue int) { + view.requestChangeObjects("ChangeBaseTileY", func(entry *level.ObjectMainEntry) { entry.Y = level.CoordinateAt(byte(newValue), entry.Y.Fine()) }) + }) + values.RenderUnifiedSliderInt(readOnly, "Fine Y", fineYUnifier, + func(u values.Unifier) int { return int(u.Unified().(byte)) }, + func(value int) string { return "%d" }, + 0, level.FineCoordinatesPerTileSide-1, + func(newValue int) { + view.requestChangeObjects("ChangeFineY", func(entry *level.ObjectMainEntry) { entry.Y = level.CoordinateAt(entry.Y.Tile(), byte(newValue)) }) + }) + + values.RenderUnifiedRotation(readOnly, "Rotation X", rotationXUnifier, + 0, 0x0FF, + values.RotationInfo{Horizontal: true, Positive: false, Clockwise: false}, + func(newValue int) { + view.requestChangeObjects("ChangeBaseRotationX", func(entry *level.ObjectMainEntry) { entry.XRotation = level.RotationUnit(newValue) }) + }) + values.RenderUnifiedRotation(readOnly, "Rotation Y", rotationYUnifier, + 0, 0x0FF, + values.RotationInfo{Horizontal: false, Positive: true, Clockwise: true}, + func(newValue int) { + view.requestChangeObjects("ChangeBaseRotationY", func(entry *level.ObjectMainEntry) { entry.YRotation = level.RotationUnit(newValue) }) + }) + values.RenderUnifiedRotation(readOnly, "Rotation Z", rotationZUnifier, + 0, 0x0FF, + values.RotationInfo{Horizontal: false, Positive: false, Clockwise: true}, + func(newValue int) { + view.requestChangeObjects("ChangeBaseRotationZ", func(entry *level.ObjectMainEntry) { entry.ZRotation = level.RotationUnit(newValue) }) + }) + + if shouldRenderRotationView { + var rotation mgl.Vec3 + if rotationXUnifier.IsUnique() { + rotation[0] = float32(rotationXUnifier.Unified().(int32)*360) / 256.0 + } + if rotationYUnifier.IsUnique() { + rotation[1] = float32(rotationYUnifier.Unified().(int32)*360) / 256.0 + } + if rotationZUnifier.IsUnique() { + rotation[2] = float32(rotationZUnifier.Unified().(int32)*360) / 256.0 + } + view.textureRenderer.Render(func() { view.orientationView.Render(rotation) }) + imgui.ImageV(gui.TextureIDForColorTexture(view.textureRenderer.Handle()), + imgui.Vec2{X: 200 * view.guiScale, Y: 200 * view.guiScale}, + imgui.Vec2{X: 0.0, Y: 0.0}, imgui.Vec2{X: 1.0, Y: 1.0}, + imgui.Vec4{X: 1, Y: 1, Z: 1, W: 1.0}, imgui.Vec4{X: 0, Y: 0, Z: 0, W: 0}) + } + + values.RenderUnifiedSliderInt(readOnly, "Hitpoints", hitpointsUnifier, + func(u values.Unifier) int { return int(u.Unified().(int16)) }, + func(value int) string { return "%d" }, + 0, 10000, + func(newValue int) { + view.requestChangeObjects("ChangeBaseHitpoints", func(entry *level.ObjectMainEntry) { entry.Hitpoints = int16(newValue) }) + }) + imgui.TreePop() + } + if imgui.TreeNodeV("Extra Properties", imgui.TreeNodeFlagsFramed) { + view.renderProperties(lvl, objects, readOnly, + func(obj *level.ObjectMainEntry) *interpreters.Instance { return lvl.ObjectExtraData(obj) }) + imgui.TreePop() + } + if imgui.TreeNodeV("Class Properties", imgui.TreeNodeFlagsFramed) { + view.renderProperties(lvl, objects, readOnly, + func(obj *level.ObjectMainEntry) *interpreters.Instance { return lvl.ObjectClassData(obj) }) + view.renderBlockPuzzleControl(lvl, objects, readOnly) + imgui.TreePop() + } + + imgui.PopItemWidth() +} + +func (view *ObjectsView) renderProperties(lvl *level.Level, objects []*level.ObjectMainEntry, readOnly bool, + interpreterRetriever func(*level.ObjectMainEntry) *interpreters.Instance) { + propertyUnifier := make(map[string]*values.Unifier) + propertyDescribers := make(map[string]func(*interpreters.Simplifier)) + var propertyOrder []string + describer := func(interpreter *interpreters.Instance, key string) func(simpl *interpreters.Simplifier) { + return func(simpl *interpreters.Simplifier) { interpreter.Describe(key, simpl) } + } + + var unifyInterpreter func(string, *interpreters.Instance, bool, map[string]bool) + unifyInterpreter = func(path string, interpreter *interpreters.Instance, first bool, thisKeys map[string]bool) { + for _, key := range interpreter.Keys() { + fullPath := path + key + thisKeys[fullPath] = true + if unifier, existing := propertyUnifier[fullPath]; existing || first { + if !existing { + u := values.NewUnifier() + unifier = &u + propertyUnifier[fullPath] = unifier + propertyDescribers[fullPath] = describer(interpreter, key) + propertyOrder = append(propertyOrder, fullPath) + } + unifier.Add(int32(interpreter.Get(key))) + } + } + for _, key := range interpreter.ActiveRefinements() { + unifyInterpreter(path+key+".", interpreter.Refined(key), first, thisKeys) + } + } + + for index, obj := range objects { + interpreter := interpreterRetriever(obj) + + thisKeys := make(map[string]bool) + unifyInterpreter("", interpreter, index == 0, thisKeys) + { + var toRemove []string + for previousKey := range propertyUnifier { + if !thisKeys[previousKey] { + toRemove = append(toRemove, previousKey) + } + } + for _, key := range toRemove { + delete(propertyUnifier, key) + } + } + } + + lastTitle := "" + for _, key := range propertyOrder { + if unifier, existing := propertyUnifier[key]; existing { + subKeys := strings.Split(key, ".") + baseKey := "" + if len(subKeys) > 1 { + baseKey = subKeys[0] + } + if baseKey != lastTitle { + imgui.Separator() + imgui.Text(baseKey + ":") + lastTitle = baseKey + } + view.renderPropertyControl(lvl, readOnly, key, *unifier, propertyDescribers[key], + func(modifier func(uint32) uint32) { + view.requestPropertiesChange(interpreterRetriever, key, modifier) // nolint: scopelint + }) + } + } +} + +func (view *ObjectsView) renderPropertyControl(lvl *level.Level, readOnly bool, + fullKey string, unifier values.Unifier, describer func(*interpreters.Simplifier), + updater func(func(uint32) uint32)) { + keys := strings.Split(fullKey, ".") + key := keys[len(keys)-1] + label := key + "###" + fullKey + _, _, levelHeight := lvl.Size() + tileHeightFormatter := tileHeightFormatterFor(levelHeight) + objectHeightFormatter := objectHeightFormatterFor(levelHeight) + moveTileHeightFormatter := moveTileHeightFormatterFor(levelHeight) + + objTypeRenderer := values.ObjectTypeControlRenderer{ + Meta: view.gameObjects.AllProperties(), + TextCache: view.textCache, + } + simplifier := values.StandardSimplifier(readOnly, fullKey, unifier, updater, objTypeRenderer) + + simplifier.SetObjectIDHandler(func() { + values.RenderUnifiedSliderInt(readOnly, label, unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) }, + func(value int) string { return "%d" }, + 0, lvl.ObjectCapacity(), + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + }) + + addVariableKey := func() { + variableTypes := []string{"boolean", "Integer"} + typeMask := 0x1000 + + typeLabel := key + "-Type###" + fullKey + "-Type" + values.RenderUnifiedCombo(readOnly, typeLabel, unifier, + func(u values.Unifier) int { + value := u.Unified().(int32) + result := 0 + if (value & int32(typeMask)) != 0 { + result = 1 + } + return result + }, + func(index int) string { return variableTypes[index] }, + len(variableTypes), + func(newIndex int) { + setMask := uint32(0) + if newIndex > 0 { + setMask |= uint32(typeMask) + } + updater(func(oldValue uint32) uint32 { + return (oldValue & uint32(0xE000)) | setMask + }) + }) + + valueLabel := key + "-Value###" + fullKey + "-Value" + switch { + case unifier.IsUnique(): + key := unifier.Unified().(int32) + isIntegerVar := (key & int32(typeMask)) != 0 + limit := archive.BooleanVarCount + if isIntegerVar { + limit = archive.IntegerVarCount + } + values.RenderUnifiedSliderInt(readOnly, valueLabel, unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) & 0x1FF }, + func(value int) string { + if isIntegerVar { + return fmt.Sprintf("%%d: %s", view.varInfoProvider.IntegerVariable(value).Name) + } + return fmt.Sprintf("%%d: %s", view.varInfoProvider.BooleanVariable(value).Name) + }, + 0, limit-1, + func(newValue int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x01FF) + result |= uint32(newValue) & uint32(0x01FF) + return result + }) + }) + + case !unifier.IsUnique(): + imgui.LabelText(valueLabel, "(multiple)") + default: + imgui.LabelText(valueLabel, "") + } + } + + simplifier.SetSpecialHandler("VariableKey", addVariableKey) + simplifier.SetSpecialHandler("VariableCondition", func() { + addVariableKey() + + comparisons := []string{ + "Var == Val", + "Var < Val", + "Var <= Val", + "Var > Val", + "Var >= Val", + "Var != Val", + } + + comboLabel := key + "-Check###" + fullKey + "-Value" + + values.RenderUnifiedCombo(readOnly, comboLabel, unifier, + func(u values.Unifier) int { + key := unifier.Unified().(int32) + return int((key >> 13) & 0x7) + }, + func(index int) string { return comparisons[index] }, + len(comparisons), + func(newIndex int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0xE000) + result |= uint32(newIndex<<13) & uint32(0xE000) + return result + }) + }) + }) + simplifier.SetSpecialHandler("LockVariable", func() { + limit := archive.BooleanVarCount + valueLabel := key + "###" + fullKey + values.RenderUnifiedSliderInt(readOnly, valueLabel, unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) & 0x1FF }, + func(value int) string { + return fmt.Sprintf("%%d: %s", view.varInfoProvider.BooleanVariable(value).Name) + }, + 0, limit-1, + func(newValue int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x01FF) + result |= uint32(newValue) & uint32(0x01FF) + return result + }) + }) + }) + + simplifier.SetSpecialHandler("BinaryCodedDecimal", func() { + values.RenderUnifiedSliderInt(readOnly, label, unifier, + func(u values.Unifier) int { return int(numbers.FromBinaryCodedDecimal(uint16(u.Unified().(int32)))) }, + func(value int) string { return "%03d" }, + 0, 999, + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(numbers.ToBinaryCodedDecimal(uint16(newValue))) }) + }) + }) + + simplifier.SetSpecialHandler("LevelTexture", func() { + atlas := lvl.TextureAtlas() + selectedIndex := -1 + if unifier.IsUnique() { + selectedIndex = int(unifier.Unified().(int32)) + } + + values.RenderUnifiedSliderInt(readOnly, key+" (atlas index)###"+fullKey+"-atlas", unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) }, + func(value int) string { return "%d" }, + 0, len(atlas)-1, + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + render.TextureSelector(label, -1, view.guiScale, len(atlas), selectedIndex, + view.textureCache, + func(index int) resource.Key { + return resource.KeyOf(ids.LargeTextures.Plus(int(atlas[index])), resource.LangAny, 0) + }, + func(index int) string { return view.textureName(int(atlas[index])) }, + func(index int) { + if !readOnly { + updater(func(oldValue uint32) uint32 { return uint32(index) }) + } + }) + }) + simplifier.SetSpecialHandler("MaterialOrLevelTexture", func() { + types := []string{"Material", "Level Texture"} + selectedType := -1 + selectedIndex := -1 + if unifier.IsUnique() { + value := int(unifier.Unified().(int32)) + selectedType = (value >> 7) & 1 + selectedIndex = value & 0x7F + } + + values.RenderUnifiedCombo(readOnly, key+"###"+fullKey+"-Type", unifier, + func(u values.Unifier) int { return selectedType }, + func(index int) string { return types[index] }, + len(types), + func(newIndex int) { + updater(func(oldValue uint32) uint32 { + newValue := uint32(0) + if newIndex > 0 { + newValue = 0x80 + } + return newValue + }) + }) + selectorLabel := key + "-Texture###" + fullKey + "-Texture" + if selectedType == 0 { + resInfo, _ := ids.Info(ids.ObjectMaterialBitmaps) + render.TextureSelector(selectorLabel, -1, view.guiScale, resInfo.MaxCount, selectedIndex, + view.textureCache, + func(index int) resource.Key { + return resource.KeyOf(ids.ObjectMaterialBitmaps.Plus(index), resource.LangAny, 0) + }, + func(index int) string { return fmt.Sprintf("%3d", index) }, + func(index int) { + if !readOnly { + updater(func(oldValue uint32) uint32 { return uint32(index) }) + } + }) + } else { + atlas := lvl.TextureAtlas() + render.TextureSelector(selectorLabel, -1, view.guiScale, len(atlas), selectedIndex, + view.textureCache, + func(index int) resource.Key { + return resource.KeyOf(ids.LargeTextures.Plus(int(atlas[index])), resource.LangAny, 0) + }, + func(index int) string { return view.textureName(int(atlas[index])) }, + func(index int) { + if !readOnly { + updater(func(oldValue uint32) uint32 { return uint32(0x80 | index) }) + } + }) + } + }) + + simplifier.SetSpecialHandler("TileHeight", func() { + values.RenderUnifiedSliderInt(readOnly, label, unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) }, + tileHeightFormatter, + 0, int(level.TileHeightUnitMax-1), + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + }) + simplifier.SetSpecialHandler("ObjectHeight", func() { + values.RenderUnifiedSliderInt(readOnly, label, unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) }, + objectHeightFormatter, + 0, 255, + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + }) + simplifier.SetSpecialHandler("MoveTileHeight", func() { + values.RenderUnifiedSliderInt(readOnly, label, unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) }, + moveTileHeightFormatter, + 0, 0x0FFF, + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + }) + + simplifier.SetSpecialHandler("TileType", func() { + values.RenderUnifiedCombo(readOnly, label, unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) }, + func(index int) string { return level.TileType(index).String() }, + len(level.TileTypes()), + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + }) + + simplifier.SetSpecialHandler("MultiAnimation", func() { + /* + animationTypes := []string{"Random"} + selectedType := -1 + if unifier.IsUnique() { + value := int(unifier.Unified().(int32)) + selectedType = value >> 12 + } + + values.RenderUnifiedCombo(readOnly, key+"###"+fullKey+"-Type", unifier, + func(u values.Unifier) int { + key := unifier.Unified().(int32) + return int(key >> 12) + }, + func(index int) string { return animationTypes[index] }, + len(animationTypes), + func(newIndex int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0xF000) + result |= uint32(newIndex<<12) & uint32(0xF000) + return result + }) + }) + */ + + // if selectedType == 0 { + values.RenderUnifiedSliderInt(readOnly, key+" infrequency###"+fullKey+"-infrequency", unifier, + func(u values.Unifier) int { + key := unifier.Unified().(int32) + return int(key & 0x0FFF) + }, + func(value int) string { return "%d" }, + 0, 0x0FFF, + func(newValue int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x0FFF) + result |= uint32(newValue) & uint32(0x0FFF) + return result + }) + }) + //} + }) + + simplifier.SetSpecialHandler("PictureSource", func() { + indexConverter := func(u values.Unifier) int { return int(u.Unified().(int32)) & 0x007F } + indexUpdater := func(newValue int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x007F) + result |= uint32(newValue) & uint32(0x007F) + return result + }) + } + + sourceType := []string{"Data", "ObjectID"} + selectedType := -1 + if unifier.IsUnique() { + value := int(unifier.Unified().(int32)) + selectedType = (value & 0x1000) >> 12 + } + + imgui.Separator() + imgui.Text(key + ":") + + values.RenderUnifiedCombo(readOnly, "Source###"+fullKey+"-Source", unifier, + func(u values.Unifier) int { + key := unifier.Unified().(int32) + return int((key & 0x1000) >> 12) + }, + func(index int) string { return sourceType[index] }, + len(sourceType), + func(newIndex int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x1000) + result |= uint32(newIndex<<12) & uint32(0x1000) + return result + }) + }) + + if selectedType == 0 { + dataType := []string{"Screen", "Custom", "Text", "Scrolling Text"} + textureData := -1 + index := -1 + selectedDataType := -1 + + if unifier.IsUnique() { + value := int(unifier.Unified().(int32)) + textureData = value & 0x0FFF + index = textureData & 0x007F + selectedDataType = (value & 0x0180) >> 7 + } + + values.RenderUnifiedCombo(readOnly, "Type###"+fullKey+"-Type", unifier, + func(u values.Unifier) int { + key := unifier.Unified().(int32) + return int((key & 0x0180) >> 7) + }, + func(index int) string { return dataType[index] }, + len(dataType), + func(newIndex int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x0180) + result |= uint32(newIndex<<7) & uint32(0x0180) + return result + }) + }) + + if selectedDataType == 0 { // Screen + resInfo, _ := ids.Info(ids.ScreenTextures) + + values.RenderUnifiedSliderInt(readOnly, "Index###"+fullKey+"-Index", unifier, + indexConverter, + func(value int) string { return "%d" }, + 0, resInfo.MaxCount-1, + indexUpdater) + + render.TextureSelector(key+"-Screen###"+fullKey+"-Screen", -1, view.guiScale, resInfo.MaxCount, index, + view.textureCache, + func(index int) resource.Key { + return resource.KeyOf(ids.ScreenTextures.Plus(index), resource.LangAny, 0) + }, + func(index int) string { return fmt.Sprintf("%3d", index) }, + func(index int) { + if !readOnly { + indexUpdater(index) + } + }) + } else if selectedDataType == 1 { // Custom + values.RenderUnifiedSliderInt(readOnly, "Index###"+fullKey+"-Index", unifier, + indexConverter, + func(value int) string { + if value >= 0x78 && value <= (0x78+8) { + return "%d - Camera" + } else if value == 0x77 { + return "%d - Static" + } else if value == 0x76 { + return "%d - SHODAN Proximity" + } else if value >= 0x70 && value <= (0x70+6) { + return "%d - Automap" + } else { + return "%d" + } + }, + 0, 0x007F, + indexUpdater) + + resInfo, _ := ids.Info(ids.ObjectMaterialBitmaps) + if index < resInfo.MaxCount { + render.TextureImage("Preview", view.textureCache, resource.KeyOf(ids.ObjectMaterialBitmaps.Plus(index), resource.LangAny, 0), imgui.Vec2{X: 64 * view.guiScale, Y: 64 * view.guiScale}) + } + } else if selectedDataType == 2 { // Text + values.RenderUnifiedSliderInt(readOnly, "Index###"+fullKey+"-Index", unifier, + indexConverter, + func(value int) string { + if value == 0x007F { + return "%d - (Random number)" + } else { + return "%d" + } + }, + 0, 0x007F, + indexUpdater) + } else if selectedDataType == 3 { // Scrolling Text + values.RenderUnifiedSliderInt(readOnly, "Index###"+fullKey+"-Index", unifier, + indexConverter, + func(value int) string { return "%d" }, + 0, 0x007F, + indexUpdater) + } + + if selectedDataType == 2 || selectedDataType == 3 { + fontNames := []string{"Large Tech", "Small Tech"} + values.RenderUnifiedCombo(readOnly, "Font###"+fullKey+"-Font", unifier, + func(u values.Unifier) int { + key := unifier.Unified().(int32) + return int((key & 0x0800) >> 11) + }, + func(value int) string { return fontNames[value] }, + len(fontNames), + func(newValue int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x0800) + result |= uint32(newValue<<11) & uint32(0x0800) + return result + }) + }) + } + + values.RenderUnifiedSliderInt(readOnly, "Scale###"+fullKey+"-Scale", unifier, + func(u values.Unifier) int { + key := unifier.Unified().(int32) + return int((key & 0x0600) >> 9) + }, + func(value int) string { return "%d" }, + 0, 3, + func(newValue int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x0600) + result |= uint32(newValue<<9) & uint32(0x0600) + return result + }) + }) + } else { + // Indirection to another object + values.RenderUnifiedSliderInt(readOnly, "ObjectID###"+fullKey+"-Object", unifier, + func(u values.Unifier) int { return int(u.Unified().(int32)) & 0x0FFF }, + func(value int) string { return "%d" }, + 0, lvl.ObjectCapacity(), + func(newValue int) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^uint32(0x0FFF) + result |= uint32(newValue) & uint32(0x0FFF) + return result + }) + }) + } + }) + + describer(simplifier) +} + +func (view *ObjectsView) renderBlockPuzzleControl(lvl *level.Level, objects []*level.ObjectMainEntry, readOnly bool) { + var blockPuzzleData *interpreters.Instance + + if len(objects) == 1 { + interpreter := lvl.ObjectClassData(objects[0]) + isBlockPuzzle := interpreter.Refined("Puzzle").Get("Type") == 0x10 + if isBlockPuzzle { + blockPuzzleData = interpreter.Refined("Puzzle").Refined("Block") + } + } + if blockPuzzleData != nil { + blockPuzzleDataID := level.ObjectID(blockPuzzleData.Get("StateStoreObjectID")) + dataObj := lvl.Object(blockPuzzleDataID) + blockPuzzleDataState := lvl.ObjectClassData(dataObj).Raw() + expectedTriple := object.TripleFrom(int(object.ClassTrap), 0, 1) + + imgui.Separator() + if (dataObj != nil) && (dataObj.InUse != 0) && + (dataObj.Triple() == expectedTriple) && + (len(blockPuzzleDataState) >= (16 + 6)) { + blockLayout := blockPuzzleData.Get("Layout") + blockWidth := int((blockLayout >> 20) & 7) + blockHeight := int((blockLayout >> 24) & 7) + state := level.NewBlockPuzzleState(blockPuzzleDataState[6:6+16], blockHeight, blockWidth) + startRow := 1 + (7-blockHeight)/2 + startColumn := 1 + (7-blockWidth)/2 + var cells [9][9]string + + placeConnector := func(side, offset int, text string) { + xOffsets := []int{offset, offset, -1, blockWidth} + yOffsets := []int{-1, blockHeight, offset, offset} + y := startRow + yOffsets[side] + x := startColumn + xOffsets[side] + + if (x >= 0) && (x < 9) && (y >= 0) && (y < 9) { + cells[y][x] = text + } + } + stateMapping := []string{" ", "X", "+", "(+)", "F", "(F)", "H", "(H)"} + + for row := 0; row < blockHeight; row++ { + for col := 0; col < blockWidth; col++ { + value := state.CellValue(row, col) + cells[startRow+row][startColumn+col] = stateMapping[value] + } + } + placeConnector(int((blockLayout>>7)&3), int((blockLayout>>4)&7), "S") + placeConnector(int((blockLayout>>15)&3), int((blockLayout>>12)&7), "D") + + cellSize := imgui.Vec2{X: imgui.TextLineHeightWithSpacing() * 1.5, Y: imgui.TextLineHeightWithSpacing() * 1.5} + for x := 0; x < 9; x++ { + imgui.BeginGroup() + for y := 0; y < 9; y++ { + cellText := cells[y][x] + imgui.PushID(fmt.Sprintf("%d:%d", x, y)) + if len(cellText) > 0 { + if imgui.ButtonV(cellText, cellSize) && !readOnly { + clickRow := y - startRow + clickCol := x - startColumn + if (clickRow >= 0) && (clickRow < blockHeight) && (clickCol >= 0) && (clickCol < blockWidth) { + oldValue := state.CellValue(clickRow, clickCol) + state.SetCellValue(clickRow, clickCol, (8+oldValue+1)%8) + + // The following seems to do nothing, yet the data is still synchronized. + // This appears to be a bad design - maybe find a better way to design the concept + // of block puzzle references... + view.requestAction("ChangeBlockPuzzle", func() error { + return view.editor.ChangeObjects(func(entry *level.ObjectMainEntry) {}) + }) + } + } + } else { + imgui.Dummy(cellSize) + } + imgui.PopID() + } + imgui.EndGroup() + imgui.SameLine() + } + } else { + imgui.Text("No proper state store found for block puzzle!\n'StateStoreObjectID' must refer to a " + expectedTriple.String() + " object.") + } + } +} + +func (view *ObjectsView) requestChangeObjects(name string, modifier func(*level.ObjectMainEntry)) { + view.requestAction(name, func() error { + return view.editor.ChangeObjects(modifier) + }) +} + +func (view *ObjectsView) requestPropertiesChange(interpreterRetriever func(*level.ObjectMainEntry) *interpreters.Instance, + key string, modifier func(uint32) uint32) { + subKeys := strings.Split(key, ".") + valueIndex := len(subKeys) - 1 + + view.requestAction("ChangeProperties", func() error { + return view.editor.ChangeObjects(func(obj *level.ObjectMainEntry) { + interpreter := interpreterRetriever(obj) + for subIndex := 0; subIndex < valueIndex; subIndex++ { + interpreter = interpreter.Refined(subKeys[subIndex]) + } + subKey := subKeys[valueIndex] + interpreter.Set(subKey, modifier(interpreter.Get(subKey))) + }) + }) +} + +func (view *ObjectsView) requestDeleteObjects() { + view.requestAction("DeleteObjects", view.editor.DeleteObjects) +} + +func (view *ObjectsView) requestAction(name string, nested func() error) { + err := view.registry.Register(cmd.Named(name), + cmd.Forward(view.restoreFocusTask()), + cmd.Nested(nested), + cmd.Reverse(view.restoreFocusTask())) + if err != nil { + panic(err) + } +} + +func (view *ObjectsView) restoreFocusTask() cmd.Task { + return func(modder world.Modder) error { + view.model.restoreFocus = true + return nil + } +} + +func (view *ObjectsView) tripleName(triple object.Triple) string { + suffix := hintUnknown + linearIndex := view.gameObjects.AllProperties().TripleIndex(triple) + if linearIndex >= 0 { + key := resource.KeyOf(ids.ObjectLongNames, resource.LangDefault, linearIndex) + objName, err := view.textCache.Text(key) + if err == nil { + suffix = objName + } + } + return triple.String() + ": " + suffix +} + +func (view *ObjectsView) textureName(index int) string { + key := resource.KeyOf(ids.TextureNames, resource.LangDefault, index) + name, err := view.textCache.Text(key) + suffix := "" + if err == nil { + suffix = ": " + name + } + return fmt.Sprintf("%3d", index) + suffix +} diff --git a/modding/hacked/editor/levels/ObjectsViewModel.go b/modding/hacked/editor/levels/ObjectsViewModel.go new file mode 100644 index 0000000..072a667 --- /dev/null +++ b/modding/hacked/editor/levels/ObjectsViewModel.go @@ -0,0 +1,10 @@ +package levels + +type objectsViewModel struct { + restoreFocus bool + windowOpen bool +} + +func freshObjectsViewModel() objectsViewModel { + return objectsViewModel{} +} diff --git a/modding/hacked/editor/levels/TextureDisplay.go b/modding/hacked/editor/levels/TextureDisplay.go new file mode 100644 index 0000000..4260c93 --- /dev/null +++ b/modding/hacked/editor/levels/TextureDisplay.go @@ -0,0 +1,64 @@ +package levels + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" +) + +// TextureDisplay is an enumeration which texture to display in a 2D map view. +type TextureDisplay int + +// TextureDisplay constants are listed below. +const ( + TextureDisplayFloor TextureDisplay = 0 + TextureDisplayWall TextureDisplay = 1 + TextureDisplayCeiling TextureDisplay = 2 +) + +// String returns a textual representation. +func (display TextureDisplay) String() string { + switch display { + case TextureDisplayFloor: + return "Floor" + case TextureDisplayWall: + return "Wall" + case TextureDisplayCeiling: + return "Ceiling" + default: + return fmt.Sprintf("Unknown%d", int(display)) + } +} + +// TextureDisplays returns all TextureDisplay constants. +func TextureDisplays() []TextureDisplay { + return []TextureDisplay{TextureDisplayFloor, TextureDisplayWall, TextureDisplayCeiling} +} + +// TextureDisplayFunc is one that resolves texture properties. +type TextureDisplayFunc func(*level.TileMapEntry) (atlasIndex level.AtlasIndex, textureRotations int) + +// FloorTexture returns the properties for the floor. +func FloorTexture(tile *level.TileMapEntry) (atlasIndex level.AtlasIndex, textureRotations int) { + return tile.TextureInfo.FloorTextureIndex(), tile.Floor.TextureRotations() +} + +// CeilingTexture returns the properties for the ceiling. +func CeilingTexture(tile *level.TileMapEntry) (atlasIndex level.AtlasIndex, textureRotations int) { + return tile.TextureInfo.CeilingTextureIndex(), tile.Ceiling.TextureRotations() +} + +// WallTexture returns the properties for the floor. +func WallTexture(tile *level.TileMapEntry) (atlasIndex level.AtlasIndex, textureRotations int) { + return tile.TextureInfo.WallTextureIndex(), 0 +} + +// Func returns the display func for the current display setting. +func (display TextureDisplay) Func() TextureDisplayFunc { + if display == TextureDisplayFloor { + return FloorTexture + } else if display == TextureDisplayCeiling { + return CeilingTexture + } + return WallTexture +} diff --git a/modding/hacked/editor/levels/TilesView.go b/modding/hacked/editor/levels/TilesView.go new file mode 100644 index 0000000..bb2c090 --- /dev/null +++ b/modding/hacked/editor/levels/TilesView.go @@ -0,0 +1,551 @@ +package levels + +import ( + "fmt" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/editor/values" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// TilesView is for tile properties. +type TilesView struct { + editor *edit.LevelEditorService + textCache *text.Cache + textureCache *graphics.TextureCache + + guiScale float32 + registry cmd.Registry + model tilesViewModel +} + +// NewTilesView returns a new instance. +func NewTilesView(editor *edit.LevelEditorService, + guiScale float32, textCache *text.Cache, textureCache *graphics.TextureCache, registry cmd.Registry) *TilesView { + view := &TilesView{ + editor: editor, + textCache: textCache, + textureCache: textureCache, + + guiScale: guiScale, + model: freshTilesViewModel(), + registry: registry, + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *TilesView) WindowOpen() *bool { + return &view.model.windowOpen +} + +// TextureDisplay returns the current setting which textures should be displayed. +func (view TilesView) TextureDisplay() TextureDisplay { + return view.model.textureDisplay +} + +// ColorDisplay returns the current setting which colors should be displayed. +func (view TilesView) ColorDisplay() ColorDisplay { + lvl := view.editor.Level() + if lvl.IsCyberspace() { + return view.model.cyberColorDisplay + } + return view.model.shadowDisplay +} + +// Render renders the view. +func (view *TilesView) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + lvl := view.editor.Level() + tiles := view.editor.Tiles() + readOnly := view.editor.IsReadOnly() + title := fmt.Sprintf("Level Tiles, %d selected", len(tiles)) + if readOnly { + title += hintReadOnly + } + imgui.SetNextWindowSizeV(imgui.Vec2{X: 400 * view.guiScale, Y: 500 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV(title+"###Level Tiles", view.WindowOpen(), 0) { + view.renderContent(lvl, tiles, readOnly) + } + imgui.End() + } +} + +func (view *TilesView) renderContent(lvl *level.Level, tiles []*level.TileMapEntry, readOnly bool) { + isCyberspace := lvl.IsCyberspace() + tileTypeUnifier := values.NewUnifier() + floorHeightUnifier := values.NewUnifier() + ceilingHeightUnifier := values.NewUnifier() + slopeHeightUnifier := values.NewUnifier() + slopeControlUnifier := values.NewUnifier() + musicZoneUnifier := values.NewUnifier() + + floorPaletteIndexUnifier := values.NewUnifier() + ceilingPaletteIndexUnifier := values.NewUnifier() + flightPullTypeUnifier := values.NewUnifier() + gameOfLightStateUnifier := values.NewUnifier() + + floorTextureIndexUnifier := values.NewUnifier() + floorTextureRotationsUnifier := values.NewUnifier() + ceilingTextureIndexUnifier := values.NewUnifier() + ceilingTextureRotationsUnifier := values.NewUnifier() + wallTextureIndexUnifier := values.NewUnifier() + wallTextureOffsetUnifier := values.NewUnifier() + useAdjacentWallTextureUnifier := values.NewUnifier() + wallTexturePatternUnifier := values.NewUnifier() + floorLightUnifier := values.NewUnifier() + floorLightDeltaUnifier := values.NewUnifier() + ceilingLightUnifier := values.NewUnifier() + ceilingLightDeltaUnifier := values.NewUnifier() + deconstructedUnifier := values.NewUnifier() + perildUnifier := values.NewUnifier() + floorHazardUnifier := values.NewUnifier() + ceilingHazardUnifier := values.NewUnifier() + + for _, tile := range tiles { + tileTypeUnifier.Add(tile.Type) + floorHeightUnifier.Add(tile.Floor.AbsoluteHeight()) + ceilingHeightUnifier.Add(tile.Ceiling.AbsoluteHeight()) + slopeHeightUnifier.Add(tile.SlopeHeight) + slopeControlUnifier.Add(tile.Flags.SlopeControl()) + musicZoneUnifier.Add(tile.Flags.MusicZone()) + perildUnifier.Add(tile.Flags.Peril()) + if isCyberspace { + floorPaletteIndexUnifier.Add(tile.TextureInfo.FloorPaletteIndex()) + ceilingPaletteIndexUnifier.Add(tile.TextureInfo.CeilingPaletteIndex()) + flightPullTypeUnifier.Add(tile.Flags.ForCyberspace().FlightPull()) + gameOfLightStateUnifier.Add(tile.Flags.ForCyberspace().GameOfLifeState()) + } else { + flags := tile.Flags.ForRealWorld() + floorTextureIndexUnifier.Add(int(tile.TextureInfo.FloorTextureIndex())) + floorTextureRotationsUnifier.Add(tile.Floor.TextureRotations()) + ceilingTextureIndexUnifier.Add(int(tile.TextureInfo.CeilingTextureIndex())) + ceilingTextureRotationsUnifier.Add(tile.Ceiling.TextureRotations()) + wallTextureIndexUnifier.Add(int(tile.TextureInfo.WallTextureIndex())) + wallTextureOffsetUnifier.Add(flags.WallTextureOffset()) + useAdjacentWallTextureUnifier.Add(flags.UseAdjacentWallTexture()) + wallTexturePatternUnifier.Add(flags.WallTexturePattern()) + floorLightUnifier.Add(level.GradesOfShadow - 1 - flags.FloorShadow()) + floorLightDeltaUnifier.Add(tile.LightDelta.OfFloor()) + ceilingLightUnifier.Add(level.GradesOfShadow - 1 - flags.CeilingShadow()) + ceilingLightDeltaUnifier.Add(tile.LightDelta.OfCeiling()) + deconstructedUnifier.Add(flags.Deconstructed()) + floorHazardUnifier.Add(tile.Floor.HasHazard()) + ceilingHazardUnifier.Add(tile.Ceiling.HasHazard()) + } + } + + imgui.PushItemWidth(-250 * view.guiScale) + + _, _, levelHeight := lvl.Size() + tileHeightFormatter := tileHeightFormatterFor(levelHeight) + + tileTypes := level.TileTypes() + values.RenderUnifiedCombo(readOnly, "Tile Type", tileTypeUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.TileType)) }, + func(value int) string { return tileTypes[value].String() }, + len(tileTypes), + func(newValue int) { view.changeTiles(setTileTypeTo(level.TileType(newValue))) }) + values.RenderUnifiedSliderInt(readOnly, "Floor Height", floorHeightUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.TileHeightUnit)) }, + tileHeightFormatter, + 0, int(level.TileHeightUnitMax)-1, + func(newValue int) { view.changeTiles(setFloorHeightTo(level.TileHeightUnit(newValue))) }) + values.RenderUnifiedSliderInt(readOnly, "Ceiling Height (abs)", ceilingHeightUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.TileHeightUnit)) }, + tileHeightFormatter, + 1, int(level.TileHeightUnitMax), + func(newValue int) { view.changeTiles(setCeilingHeightTo(level.TileHeightUnit(newValue))) }) + values.RenderUnifiedSliderInt(readOnly, "Slope Height", slopeHeightUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.TileHeightUnit)) }, + tileHeightFormatter, + 0, int(level.TileHeightUnitMax)-1, + func(newValue int) { view.changeTiles(setSlopeHeightTo(level.TileHeightUnit(newValue))) }) + slopeControls := level.TileSlopeControls() + values.RenderUnifiedCombo(readOnly, "Slope Control", slopeControlUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.TileSlopeControl)) }, + func(value int) string { return slopeControls[value].String() }, + len(slopeControls), + func(newValue int) { view.changeTiles(setSlopeControlTo(slopeControls[newValue])) }) + musicZones := level.TileMusicZones() + values.RenderUnifiedCombo(readOnly, "Music Zone", musicZoneUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.TileMusicZone)) }, + func(value int) string { return musicZones[value].String() }, + len(musicZones), + func(newValue int) { view.changeTiles(setMusicZoneTo(musicZones[newValue])) }) + values.RenderUnifiedCheckboxCombo(readOnly, "Music Peril", perildUnifier, + func(newValue bool) { view.changeTiles(setPerilTo(newValue)) }) + + imgui.Separator() + + if isCyberspace { + if imgui.BeginCombo("Color View", view.model.cyberColorDisplay.String()) { + displays := ColorDisplays() + for _, display := range displays { + displayString := display.String() + + if imgui.SelectableV(displayString, display == view.model.cyberColorDisplay, 0, imgui.Vec2{}) { + view.model.cyberColorDisplay = display + } + } + imgui.EndCombo() + } + + values.RenderUnifiedSliderInt(readOnly, "Floor Color", floorPaletteIndexUnifier, + func(u values.Unifier) int { return int(u.Unified().(byte)) }, + func(value int) string { return "%d" }, + 0, bitmap.PaletteSize-1, + func(newValue int) { view.changeTiles(setFloorPaletteIndexTo(newValue)) }) + values.RenderUnifiedSliderInt(readOnly, "Ceiling Color", ceilingPaletteIndexUnifier, + func(u values.Unifier) int { return int(u.Unified().(byte)) }, + func(value int) string { return "%d" }, + 0, bitmap.PaletteSize-1, + func(newValue int) { view.changeTiles(setCeilingPaletteIndexTo(newValue)) }) + + imgui.Separator() + + flightPulls := level.CyberspaceFlightPulls() + values.RenderUnifiedCombo(readOnly, "Flight Pull Type", flightPullTypeUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.CyberspaceFlightPull)) }, + func(value int) string { return flightPulls[value].String() }, + len(flightPulls), + func(newValue int) { view.changeTiles(setFlightPullTypeTo(flightPulls[newValue])) }) + values.RenderUnifiedSliderInt(readOnly, "Game Of Life State", gameOfLightStateUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, 3, + func(newValue int) { view.changeTiles(setGameOfLightStateTo(newValue)) }) + } else { + atlas := lvl.TextureAtlas() + + if imgui.BeginCombo("Texture View", view.model.textureDisplay.String()) { + displays := TextureDisplays() + for _, display := range displays { + displayString := display.String() + + if imgui.SelectableV(displayString, display == view.model.textureDisplay, 0, imgui.Vec2{}) { + view.model.textureDisplay = display + } + } + imgui.EndCombo() + } + + values.RenderUnifiedSliderInt(readOnly, "Floor Texture (atlas index)", floorTextureIndexUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, level.FloorCeilingTextureLimit-1, + func(newValue int) { view.changeTiles(setFloorTextureIndexTo(level.AtlasIndex(newValue))) }) + view.renderTextureSelector(readOnly, "Floor Texture", floorTextureIndexUnifier, atlas, 0, level.FloorCeilingTextureLimit-1, + func(newValue int) { view.changeTiles(setFloorTextureIndexTo(level.AtlasIndex(newValue))) }) + values.RenderUnifiedSliderInt(readOnly, "Floor Texture Rotations", floorTextureRotationsUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, 3, + func(newValue int) { view.changeTiles(setFloorTextureRotationsTo(newValue)) }) + + values.RenderUnifiedSliderInt(readOnly, "Ceiling Texture (atlas index)", ceilingTextureIndexUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, level.FloorCeilingTextureLimit-1, + func(newValue int) { view.changeTiles(setCeilingTextureIndexTo(level.AtlasIndex(newValue))) }) + view.renderTextureSelector(readOnly, "Ceiling Texture", ceilingTextureIndexUnifier, atlas, 0, level.FloorCeilingTextureLimit-1, + func(newValue int) { view.changeTiles(setCeilingTextureIndexTo(level.AtlasIndex(newValue))) }) + values.RenderUnifiedSliderInt(readOnly, "Ceiling Texture Rotations", ceilingTextureRotationsUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, 3, + func(newValue int) { view.changeTiles(setCeilingTextureRotationsTo(newValue)) }) + + values.RenderUnifiedSliderInt(readOnly, "Wall Texture (atlas index)", wallTextureIndexUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, len(atlas)-1, + func(newValue int) { view.changeTiles(setWallTextureIndexTo(level.AtlasIndex(newValue))) }) + view.renderTextureSelector(readOnly, "Wall Texture", wallTextureIndexUnifier, atlas, 0, len(atlas)-1, + func(newValue int) { view.changeTiles(setWallTextureIndexTo(level.AtlasIndex(newValue))) }) + values.RenderUnifiedSliderInt(readOnly, "Wall Texture Offset", wallTextureOffsetUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.TileHeightUnit)) }, + tileHeightFormatter, + 0, int(level.TileHeightUnitMax)-1, + func(newValue int) { view.changeTiles(setWallTextureOffsetTo(level.TileHeightUnit(newValue))) }) + + values.RenderUnifiedCheckboxCombo(readOnly, "Use Adjacent Wall Texture", useAdjacentWallTextureUnifier, + func(newValue bool) { view.changeTiles(setUseAdjacentWallTextureTo(newValue)) }) + wallTexturePatterns := level.WallTexturePatterns() + values.RenderUnifiedCombo(readOnly, "Wall Texture Pattern", wallTexturePatternUnifier, + func(u values.Unifier) int { return int(u.Unified().(level.WallTexturePattern)) }, + func(value int) string { return wallTexturePatterns[value].String() }, + len(wallTexturePatterns), + func(newValue int) { view.changeTiles(setWallTexturePatternTo(wallTexturePatterns[newValue])) }) + + imgui.Separator() + + if imgui.BeginCombo("Shadow View", view.model.shadowDisplay.String()) { + displays := ColorDisplays() + for _, display := range displays { + displayString := display.String() + + if imgui.SelectableV(displayString, display == view.model.shadowDisplay, 0, imgui.Vec2{}) { + view.model.shadowDisplay = display + } + } + imgui.EndCombo() + } + + values.RenderUnifiedSliderInt(readOnly, "Floor Light", floorLightUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, level.GradesOfShadow-1, + func(newValue int) { view.changeTiles(setFloorLightTo(newValue)) }) + values.RenderUnifiedSliderInt(readOnly, "Floor Light Delta", floorLightDeltaUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, level.GradesOfShadow-1, + func(newValue int) { view.changeTiles(setFloorLightDeltaTo(newValue)) }) + values.RenderUnifiedSliderInt(readOnly, "Ceiling Light", ceilingLightUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, level.GradesOfShadow-1, + func(newValue int) { view.changeTiles(setCeilingLightTo(newValue)) }) + values.RenderUnifiedSliderInt(readOnly, "Ceiling Light Delta", ceilingLightDeltaUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, level.GradesOfShadow-1, + func(newValue int) { view.changeTiles(setCeilingLightDeltaTo(newValue)) }) + + imgui.Separator() + + values.RenderUnifiedCheckboxCombo(readOnly, "Deconstructed", deconstructedUnifier, + func(newValue bool) { view.changeTiles(setDeconstructedTo(newValue)) }) + values.RenderUnifiedCheckboxCombo(readOnly, "Floor Hazard", floorHazardUnifier, + func(newValue bool) { view.changeTiles(setFloorHazardTo(newValue)) }) + values.RenderUnifiedCheckboxCombo(readOnly, "Ceiling Hazard", ceilingHazardUnifier, + func(newValue bool) { view.changeTiles(setCeilingHazardTo(newValue)) }) + } + + imgui.PopItemWidth() +} + +func (view *TilesView) renderTextureSelector(readOnly bool, label string, unifier values.Unifier, + atlas level.TextureAtlas, minIndex, maxIndex int, changeHandler func(int)) { + selectedIndex := -1 + if unifier.IsUnique() { + selectedIndex = unifier.Unified().(int) + } + + count := maxIndex - minIndex + 1 + if count > len(atlas) { + count = len(atlas) + } + render.TextureSelector(label, -1, view.guiScale, count, selectedIndex-minIndex, + view.textureCache, + func(index int) resource.Key { + return resource.KeyOf(ids.LargeTextures.Plus(int(atlas[minIndex+index])), resource.LangAny, 0) + }, + func(index int) string { return view.textureName(int(atlas[minIndex+index])) }, + func(index int) { + if !readOnly { + changeHandler(index) + } + }) +} + +func (view *TilesView) textureName(index int) string { + key := resource.KeyOf(ids.TextureNames, resource.LangDefault, index) + name, err := view.textCache.Text(key) + suffix := "" + if err == nil { + suffix = ": " + name + } + return fmt.Sprintf("%3d", index) + suffix +} + +func setTileTypeTo(tileType level.TileType) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Type = tileType + } +} + +func setFloorHeightTo(height level.TileHeightUnit) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Floor = tile.Floor.WithAbsoluteHeight(height) + } +} + +func setCeilingHeightTo(height level.TileHeightUnit) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Ceiling = tile.Ceiling.WithAbsoluteHeight(height) + } +} + +func setSlopeHeightTo(height level.TileHeightUnit) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.SlopeHeight = height + } +} + +func setSlopeControlTo(value level.TileSlopeControl) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.WithSlopeControl(value) + } +} + +func setPerilTo(value bool) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.WithPeril(value) + } +} + +func setMusicZoneTo(value level.TileMusicZone) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.WithMusicZone(value) + } +} + +func setFloorTextureIndexTo(value level.AtlasIndex) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.TextureInfo = tile.TextureInfo.WithFloorTextureIndex(value) + } +} + +func setFloorTextureRotationsTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Floor = tile.Floor.WithTextureRotations(value) + } +} + +func setCeilingTextureIndexTo(value level.AtlasIndex) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.TextureInfo = tile.TextureInfo.WithCeilingTextureIndex(value) + } +} + +func setCeilingTextureRotationsTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Ceiling = tile.Ceiling.WithTextureRotations(value) + } +} + +func setWallTextureIndexTo(value level.AtlasIndex) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.TextureInfo = tile.TextureInfo.WithWallTextureIndex(value) + } +} + +func setWallTextureOffsetTo(value level.TileHeightUnit) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForRealWorld().WithWallTextureOffset(value).AsTileFlag() + } +} + +func setUseAdjacentWallTextureTo(value bool) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForRealWorld().WithUseAdjacentWallTexture(value).AsTileFlag() + } +} + +func setWallTexturePatternTo(value level.WallTexturePattern) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForRealWorld().WithWallTexturePattern(value).AsTileFlag() + } +} + +func setFloorLightTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForRealWorld().WithFloorShadow(level.GradesOfShadow - 1 - value).AsTileFlag() + } +} + +func setFloorLightDeltaTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.LightDelta = tile.LightDelta.WithFloor(value) + } +} + +func setCeilingLightTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForRealWorld().WithCeilingShadow(level.GradesOfShadow - 1 - value).AsTileFlag() + } +} + +func setCeilingLightDeltaTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.LightDelta = tile.LightDelta.WithCeiling(value) + } +} + +func setDeconstructedTo(value bool) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForRealWorld().WithDeconstructed(value).AsTileFlag() + } +} + +func setFloorHazardTo(value bool) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Floor = tile.Floor.WithHazard(value) + } +} + +func setCeilingHazardTo(value bool) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Ceiling = tile.Ceiling.WithHazard(value) + } +} + +func setFloorPaletteIndexTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.TextureInfo = tile.TextureInfo.WithFloorPaletteIndex(byte(value)) + } +} + +func setCeilingPaletteIndexTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.TextureInfo = tile.TextureInfo.WithCeilingPaletteIndex(byte(value)) + } +} + +func setFlightPullTypeTo(value level.CyberspaceFlightPull) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForCyberspace().WithFlightPull(value).AsTileFlag() + } +} + +func setGameOfLightStateTo(value int) tileMapEntryModifier { + return func(tile *level.TileMapEntry) { + tile.Flags = tile.Flags.ForCyberspace().WithGameOfLifeState(value).AsTileFlag() + } +} + +type tileMapEntryModifier func(*level.TileMapEntry) + +func (view *TilesView) changeTiles(modifier tileMapEntryModifier) { + err := view.registry.Register(cmd.Named("ChangeTiles"), + cmd.Forward(view.restoreFocusTask()), + cmd.Nested(func() error { return view.editor.ChangeTiles(modifier) }), + cmd.Reverse(view.restoreFocusTask())) + if err != nil { + panic(err) + } +} + +func (view *TilesView) restoreFocusTask() cmd.Task { + return func(modder world.Modder) error { + view.model.restoreFocus = true + return nil + } +} diff --git a/modding/hacked/editor/levels/TilesViewModel.go b/modding/hacked/editor/levels/TilesViewModel.go new file mode 100644 index 0000000..27c7dea --- /dev/null +++ b/modding/hacked/editor/levels/TilesViewModel.go @@ -0,0 +1,18 @@ +package levels + +type tilesViewModel struct { + textureDisplay TextureDisplay + shadowDisplay ColorDisplay + cyberColorDisplay ColorDisplay + + restoreFocus bool + windowOpen bool +} + +func freshTilesViewModel() tilesViewModel { + return tilesViewModel{ + textureDisplay: TextureDisplayFloor, + shadowDisplay: ColorDisplayNone, + cyberColorDisplay: ColorDisplayNone, + } +} diff --git a/modding/hacked/editor/levels/hazards.go b/modding/hacked/editor/levels/hazards.go new file mode 100644 index 0000000..a3275e6 --- /dev/null +++ b/modding/hacked/editor/levels/hazards.go @@ -0,0 +1,64 @@ +package levels + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" +) + +type hazardFormatter func(int) string + +func rawHazardValue(int) string { + return "raw: %d" +} + +func lbpHazardValue(value int) string { + return fmt.Sprintf("%3.1f LBP - raw: %%d", float32(value)/2) +} + +func percentHazardValue(value int) string { + return fmt.Sprintf("%v%%%% - raw: %%d", value*25) +} + +type ceilingHazardInfo struct { + title string + radiationRegister bool + formatter hazardFormatter +} + +var ceilingHazards = []ceilingHazardInfo{ + {title: "Off", radiationRegister: false, formatter: rawHazardValue}, + {title: "Radiation", radiationRegister: true, formatter: lbpHazardValue}, +} + +func currentCeilingHazard(param *level.Parameters) ceilingHazardInfo { + radiationRegister := param.RadiationRegister > 1 + if radiationRegister { + return ceilingHazards[1] + } + return ceilingHazards[0] +} + +type floorHazardInfo struct { + title string + biohazardRegister bool + isGravity bool + formatter hazardFormatter +} + +var floorHazards = []floorHazardInfo{ + {title: "Off", biohazardRegister: false, isGravity: false, formatter: rawHazardValue}, + {title: "Gravity", biohazardRegister: false, isGravity: true, formatter: percentHazardValue}, + {title: "Biohazard", biohazardRegister: true, isGravity: false, formatter: lbpHazardValue}, +} + +func currentFloorHazard(param *level.Parameters) floorHazardInfo { + biohazardRegister := param.BiohazardRegister > 1 + isGravity := param.FloorHazardIsGravity != 0 + if isGravity { + return floorHazards[1] + } else if biohazardRegister { + return floorHazards[2] + } + return floorHazards[0] +} diff --git a/modding/hacked/editor/messages/SetMessageDataCommand.go b/modding/hacked/editor/messages/SetMessageDataCommand.go new file mode 100644 index 0000000..93d9595 --- /dev/null +++ b/modding/hacked/editor/messages/SetMessageDataCommand.go @@ -0,0 +1,52 @@ +package messages + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +type messageDataEntry struct { + oldData [][]byte + newData [][]byte +} + +type setMessageDataCommand struct { + model *viewModel + + key resource.Key + showVerboseText bool + + textEntries map[resource.Language]messageDataEntry + audioEntries map[resource.Language]messageDataEntry +} + +func (cmd setMessageDataCommand) Do(modder world.Modder) error { + return cmd.perform(modder, func(entry messageDataEntry) [][]byte { return entry.newData }) +} + +func (cmd setMessageDataCommand) Undo(modder world.Modder) error { + return cmd.perform(modder, func(entry messageDataEntry) [][]byte { return entry.oldData }) +} + +func (cmd setMessageDataCommand) perform(modder world.Modder, dataResolver func(messageDataEntry) [][]byte) error { + cmd.saveEntries(modder, cmd.key.ID.Plus(cmd.key.Index), cmd.textEntries, dataResolver) + cmd.saveEntries(modder, cmd.key.ID.Plus(cmd.key.Index).Plus(300), cmd.audioEntries, dataResolver) + + cmd.model.restoreFocus = true + cmd.model.currentKey = cmd.key + cmd.model.showVerboseText = cmd.showVerboseText + return nil +} + +func (cmd setMessageDataCommand) saveEntries(modder world.Modder, id resource.ID, + entries map[resource.Language]messageDataEntry, + dataResolver func(messageDataEntry) [][]byte) { + for lang, entry := range entries { + data := dataResolver(entry) + if len(data) > 0 { + modder.SetResourceBlocks(lang, id, data) + } else { + modder.DelResource(lang, id) + } + } +} diff --git a/modding/hacked/editor/messages/View.go b/modding/hacked/editor/messages/View.go new file mode 100644 index 0000000..50bff54 --- /dev/null +++ b/modding/hacked/editor/messages/View.go @@ -0,0 +1,488 @@ +package messages + +import ( + "fmt" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/external" + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/editor/values" + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/gui" +) + +type messageInfo struct { + title string +} + +var knownMessageTypes = map[resource.ID]messageInfo{ + ids.MailsStart: {title: "Mails"}, + ids.LogsStart: {title: "Logs"}, + ids.FragmentsStart: {title: "Fragments"}, +} +var knownMessageTypesOrder = []resource.ID{ids.MailsStart, ids.LogsStart, ids.FragmentsStart} + +var knownMailTypes = []string{ + "Audio/Text", + "VMail: 'Shield' (0)", + "VMail: 'Grove' (1)", + "VMail: 'Bridge' (2)", + "VMail: 'Laser' (3, 4)", + "VMail: 'Status' (11)", + "VMail: 'Explode' (5..9)", +} + +const videoMailDisplayBase = 256 + +// View provides edit controls for messages. +type View struct { + mod *world.Mod + messageCache *text.ElectronicMessageCache + cp text.Codepage + movieCache *movie.Cache + imageCache *graphics.TextureCache + + modalStateMachine gui.ModalStateMachine + clipboard external.Clipboard + guiScale float32 + commander cmd.Commander + + model viewModel +} + +// NewMessagesView returns a new instance. +func NewMessagesView(mod *world.Mod, messageCache *text.ElectronicMessageCache, cp text.Codepage, + movieCache *movie.Cache, imageCache *graphics.TextureCache, + modalStateMachine gui.ModalStateMachine, clipboard external.Clipboard, + guiScale float32, commander cmd.Commander) *View { + view := &View{ + mod: mod, + messageCache: messageCache, + cp: cp, + movieCache: movieCache, + imageCache: imageCache, + + modalStateMachine: modalStateMachine, + clipboard: clipboard, + guiScale: guiScale, + commander: commander, + + model: freshViewModel(), + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *View) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render renders the view. +func (view *View) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + imgui.SetNextWindowSizeV(imgui.Vec2{X: 640 * view.guiScale, Y: 480 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV("Messages", view.WindowOpen(), imgui.WindowFlagsNoCollapse) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + if imgui.BeginChildV("Properties", imgui.Vec2{X: 350 * view.guiScale, Y: -200 * view.guiScale}, false, 0) { + imgui.PushItemWidth(-150 * view.guiScale) + if imgui.BeginCombo("Message Type", knownMessageTypes[view.model.currentKey.ID].title) { + for _, id := range knownMessageTypesOrder { + if imgui.SelectableV(knownMessageTypes[id].title, id == view.model.currentKey.ID, 0, imgui.Vec2{}) { + view.model.currentKey.ID = id + view.model.currentKey.Index = 0 + } + } + imgui.EndCombo() + } + info, _ := ids.Info(view.model.currentKey.ID) + index := view.model.currentKey.Index + if gui.StepSliderInt("Index", &index, 0, info.MaxCount-1) { + view.model.currentKey.Index = index + } + if imgui.BeginCombo("Language", view.model.currentKey.Lang.String()) { + languages := resource.Languages() + for _, lang := range languages { + if imgui.SelectableV(lang.String(), lang == view.model.currentKey.Lang, 0, imgui.Vec2{}) { + view.model.currentKey.Lang = lang + } + } + imgui.EndCombo() + } + + message, msgReadOnly := view.currentMessage() + availableDisplays := view.availableDisplays() + + if imgui.ButtonV("Clear All", imgui.Vec2{}) { + view.requestClearAll() + } + imgui.SameLine() + if !msgReadOnly { + if imgui.ButtonV("Remove All", imgui.Vec2{}) { + view.requestRemoveAll() + } + } else { + imgui.Text("(read-only)") + } + if view.hasAudio() { + imgui.PushID("audio") + imgui.Separator() + sound, soundReadOnly := view.currentSound() + if !sound.Empty() { + imgui.LabelText("Audio", fmt.Sprintf("%.2f sec", sound.Duration())) + if imgui.Button("Export") { + view.requestExportAudio(sound) + } + if !soundReadOnly { + imgui.SameLine() + if imgui.Button("Remove") { + view.requestRemoveAudio() + } + } + imgui.SameLine() + } else { + imgui.LabelText("Audio", "(no sound)") + } + if imgui.Button("Import") { + view.requestImportAudio() + } + imgui.SameLine() + if imgui.Button("Clear") { + view.requestClearAudio() + } + imgui.PopID() + } + imgui.Separator() + + textModes := map[bool]string{true: "Verbose", false: "Terse"} + if imgui.BeginCombo("Text Mode", textModes[view.model.showVerboseText]) { + for _, setting := range []bool{true, false} { + if imgui.SelectableV(textModes[setting], view.model.showVerboseText == setting, 0, imgui.Vec2{}) { + view.model.showVerboseText = setting + } + } + imgui.EndCombo() + } + view.renderText(false, "Title", message.Title, func(newValue string) { + view.requestTextChange(func(msg *text.ElectronicMessage) { msg.Title = text.Blocked(newValue)[0] }) + }) + view.renderText(false, "Sender", message.Sender, func(newValue string) { + view.requestTextChange(func(msg *text.ElectronicMessage) { msg.Sender = text.Blocked(newValue)[0] }) + }) + view.renderText(false, "Subject", message.Subject, func(newValue string) { + view.requestTextChange(func(msg *text.ElectronicMessage) { msg.Subject = text.Blocked(newValue)[0] }) + }) + nextMessageIndex := values.NewUnifier() + nextMessageIndex.Add(message.NextMessage) + values.RenderUnifiedSliderInt(false, "Next Message Index", nextMessageIndex, + func(u values.Unifier) int { return u.Unified().(int) }, + func(int) string { return "%d" }, + -1, 255, + func(newValue int) { + view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.NextMessage = newValue }) + }) + + isInterrupt := values.NewUnifier() + isInterrupt.Add(message.IsInterrupt) + values.RenderUnifiedCheckboxCombo(false, "Is Interrupt", isInterrupt, + func(newValue bool) { + view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.IsInterrupt = newValue }) + }) + + colorIndex := values.NewUnifier() + colorIndex.Add(message.ColorIndex) + values.RenderUnifiedSliderInt(false, "Color Index", colorIndex, + func(u values.Unifier) int { return u.Unified().(int) }, + func(int) string { return "%d" }, + -1, 255, + func(newValue int) { + view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.ColorIndex = newValue }) + }) + + if view.model.currentKey.ID == ids.MailsStart { + mailType := message.LeftDisplay - videoMailDisplayBase + 1 + selectedType := fmt.Sprintf("Unknown (0x%03X)", message.LeftDisplay) + if mailType < 0 { + mailType = 0 + } + if mailType < len(knownMailTypes) { + selectedType = knownMailTypes[mailType] + } + if msgReadOnly { + imgui.LabelText("Mail Type", selectedType) + } else if imgui.BeginCombo("Mail Type", selectedType) { + for typeIndex, typeString := range knownMailTypes { + if imgui.SelectableV(typeString, mailType == typeIndex, 0, imgui.Vec2{}) { + newIndex := -1 + if typeIndex > 0 { + newIndex = videoMailDisplayBase + (typeIndex - 1) + } + view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.LeftDisplay = newIndex }) + } + } + imgui.EndCombo() + } + } + if message.LeftDisplay < videoMailDisplayBase { + view.renderDisplayGallery(false, "Left Display", message.LeftDisplay, availableDisplays, + func(newValue int) { + view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.LeftDisplay = newValue }) + }) + view.renderDisplayGallery(false, "Right Display", message.RightDisplay, availableDisplays, + func(newValue int) { + view.requestPropertyChange(func(msg *text.ElectronicMessage) { msg.RightDisplay = newValue }) + }) + } + + imgui.PopItemWidth() + } + imgui.EndChild() + + view.renderMFDs() +} + +func (view *View) renderMFDs() { + message, _ := view.currentMessage() + + view.renderSideImage("LeftMFD", message.LeftDisplay) + imgui.SameLineV(0, 0) + textToDisplay := message.VerboseText + if imgui.BeginChildV("Text", imgui.Vec2{X: -250 * view.guiScale, Y: 190 * view.guiScale}, true, 0) { + if !view.model.showVerboseText { + textToDisplay = message.TerseText + } + + imgui.PushTextWrapPos() + if len(textToDisplay) == 0 { + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1.0, Y: 1.0, Z: 1.0, W: 0.5}) + imgui.Text("(empty)") + imgui.PopStyleColor() + } else { + imgui.Text(textToDisplay) + } + imgui.PopTextWrapPos() + } + imgui.EndChild() + view.clipboardPopup(false, "Text", textToDisplay, func(newValue string) { + view.requestTextChange(func(msg *text.ElectronicMessage) { + if view.model.showVerboseText { + msg.VerboseText = newValue + } else { + msg.TerseText = newValue + } + }) + }) + imgui.SameLineV(0, 0) + view.renderSideImage("RightMFD", message.RightDisplay) +} + +func (view View) currentMessage() (msg text.ElectronicMessage, readOnly bool) { + msg = view.messageOf(view.model.currentKey) + readOnly = len(view.mod.ModifiedBlocks(view.model.currentKey.Lang, view.model.currentKey.ID.Plus(view.model.currentKey.Index))) == 0 + return +} + +func (view View) hasAudio() bool { + return (view.model.currentKey.ID == ids.MailsStart) || (view.model.currentKey.ID == ids.LogsStart) +} + +func (view *View) currentSound() (audio.L8, bool) { + if view.hasAudio() { + key := view.model.currentKey + key.ID = key.ID.Plus(300) + sound, _ := view.movieCache.Audio(key) + readOnly := len(view.mod.ModifiedBlocks(key.Lang, key.ID.Plus(view.model.currentKey.Index))) == 0 + return sound, readOnly + } + return audio.L8{}, true +} + +func (view View) messageOf(key resource.Key) text.ElectronicMessage { + msg, cacheErr := view.messageCache.Message(key) + if cacheErr != nil { + msg = text.EmptyElectronicMessage() + } + return msg +} + +func (view *View) availableDisplays() int { + selector := view.mod.LocalizedResources(view.model.currentKey.Lang) + res, err := selector.Select(ids.MfdDataBitmaps) + if err != nil { + return 0 + } + return res.BlockCount() +} + +func (view *View) renderText(readOnly bool, label string, value string, changeCallback func(string)) { + imgui.LabelText(label, value) + view.clipboardPopup(readOnly, label, value, changeCallback) +} + +func (view *View) clipboardPopup(readOnly bool, label string, value string, changeCallback func(string)) { + if imgui.BeginPopupContextItemV(label+"-Popup", 1) { + if imgui.Selectable("Copy to Clipboard") { + view.clipboard.SetString(value) + } + if !readOnly && imgui.Selectable("Copy from Clipboard") { + newValue, err := view.clipboard.String() + if err == nil { + changeCallback(newValue) + } + } + imgui.EndPopup() + } +} + +func (view *View) renderDisplayGallery(readOnly bool, label string, index, availableDisplays int, + changeCallback func(int)) { + unifier := values.NewUnifier() + unifier.Add(index) + values.RenderUnifiedSliderInt(readOnly, label, unifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(int) string { return "%d" }, + -1, availableDisplays-1, + changeCallback) + render.TextureSelector("###"+label+" Texture", -1, view.guiScale, availableDisplays, + index, view.imageCache, + func(index int) resource.Key { + return resource.KeyOf(ids.MfdDataBitmaps, view.model.currentKey.Lang, index) + }, + func(index int) string { return fmt.Sprintf("%d", index) }, + changeCallback) +} + +func (view *View) renderSideImage(label string, index int) { + size := imgui.Vec2{X: 250 * view.guiScale, Y: 190 * view.guiScale} + imgui.PushStyleVarVec2(imgui.StyleVarWindowPadding, imgui.Vec2{X: size.X, Y: -size.Y}) + if imgui.BeginChildV(label, size, false, 0) { + if index >= 0 { + key := resource.KeyOf(ids.MfdDataBitmaps, view.model.currentKey.Lang, index) + render.TextureImage(label, view.imageCache, key, size) + } + } + imgui.PopStyleVar() + imgui.EndChild() +} + +func (view *View) requestExportAudio(sound audio.L8) { + filename := fmt.Sprintf("%05d_%s.wav", + view.model.currentKey.ID.Plus(view.model.currentKey.Index).Plus(300).Value(), + view.model.currentKey.Lang.String()) + + external.ExportAudio(view.modalStateMachine, filename, sound) +} + +func (view *View) requestRemoveAudio() { + view.requestAudioChange(nil) +} + +func (view *View) requestImportAudio() { + external.ImportAudio(view.modalStateMachine, func(sound audio.L8) { + movieData := movie.ContainSoundData(sound) + view.requestAudioChange(movieData) + }) +} + +func (view *View) requestClearAudio() { + view.requestAudioChange(view.silence()) +} + +func (view View) silence() []byte { + silence := audio.L8{SampleRate: 22050, Samples: []byte{0x80}} + return movie.ContainSoundData(silence) +} + +func (view *View) requestClearAll() { + view.requestWipe(text.EmptyElectronicMessage().Encode(view.cp), view.silence()) +} + +func (view *View) requestRemoveAll() { + view.requestWipe(nil, nil) +} + +func (view *View) requestWipe(newTextData [][]byte, newAudioData []byte) { + textEntries := make(map[resource.Language]messageDataEntry) + audioEntries := make(map[resource.Language]messageDataEntry) + textID := view.model.currentKey.ID.Plus(view.model.currentKey.Index) + for lang := resource.Language(0); lang < resource.LanguageCount; lang++ { + textEntries[lang] = messageDataEntry{ + oldData: view.mod.ModifiedBlocks(lang, textID), + newData: newTextData, + } + if view.hasAudio() { + audioEntries[lang] = messageDataEntry{ + oldData: view.mod.ModifiedBlocks(lang, textID.Plus(300)), + newData: [][]byte{newAudioData}, + } + } + } + view.requestSetMessageData(textEntries, audioEntries) +} + +func (view *View) requestTextChange(modifier func(*text.ElectronicMessage)) { + msg := view.messageOf(view.model.currentKey) + entries := make(map[resource.Language]messageDataEntry) + modifier(&msg) + + entries[view.model.currentKey.Lang] = messageDataEntry{ + oldData: view.mod.ModifiedBlocks(view.model.currentKey.Lang, view.model.currentKey.ID.Plus(view.model.currentKey.Index)), + newData: msg.Encode(view.cp), + } + view.requestSetMessageData(entries, nil) +} + +func (view *View) requestPropertyChange(modifier func(*text.ElectronicMessage)) { + entries := make(map[resource.Language]messageDataEntry) + for lang := resource.Language(0); lang < resource.LanguageCount; lang++ { + key := view.model.currentKey + key.Lang = lang + msg := view.messageOf(key) + modifier(&msg) + + entries[lang] = messageDataEntry{ + oldData: view.mod.ModifiedBlocks(lang, key.ID.Plus(key.Index)), + newData: msg.Encode(view.cp), + } + } + view.requestSetMessageData(entries, nil) +} + +func (view *View) requestAudioChange(movieData []byte) { + entries := make(map[resource.Language]messageDataEntry) + + entries[view.model.currentKey.Lang] = messageDataEntry{ + oldData: view.mod.ModifiedBlocks(view.model.currentKey.Lang, view.model.currentKey.ID.Plus(view.model.currentKey.Index).Plus(300)), + newData: [][]byte{movieData}, + } + view.requestSetMessageData(nil, entries) +} + +func (view *View) requestSetMessageData(textEntries map[resource.Language]messageDataEntry, audioEntries map[resource.Language]messageDataEntry) { + command := setMessageDataCommand{ + key: view.model.currentKey, + showVerboseText: view.model.showVerboseText, + model: &view.model, + textEntries: textEntries, + audioEntries: audioEntries, + } + view.commander.Queue(command) +} diff --git a/modding/hacked/editor/messages/ViewModel.go b/modding/hacked/editor/messages/ViewModel.go new file mode 100644 index 0000000..cd39528 --- /dev/null +++ b/modding/hacked/editor/messages/ViewModel.go @@ -0,0 +1,21 @@ +package messages + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +type viewModel struct { + windowOpen bool + restoreFocus bool + + currentKey resource.Key + showVerboseText bool +} + +func freshViewModel() viewModel { + return viewModel{ + currentKey: resource.KeyOf(ids.MailsStart, resource.LangDefault, 0), + showVerboseText: true, + } +} diff --git a/modding/hacked/editor/movies/CompressingStartState.go b/modding/hacked/editor/movies/CompressingStartState.go new file mode 100644 index 0000000..e939adf --- /dev/null +++ b/modding/hacked/editor/movies/CompressingStartState.go @@ -0,0 +1,36 @@ +package movies + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ui/gui" +) + +type compressionListenerFunc func(compressionResult) + +type compressingStartState struct { + machine gui.ModalStateMachine + view *View + + width int + height int + input movie.Scene + listener compressionListenerFunc +} + +func (state compressingStartState) Render() { + imgui.OpenPopup("Compressing...") + task := newCompressionTask(state.input, state.width, state.height) + state.machine.SetState(&compressingWaitingState{ + machine: state.machine, + view: state.view, + input: state.input, + listener: state.listener, + task: task, + }) + go task.run() +} + +func (state compressingStartState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/movies/CompressingWaitingState.go b/modding/hacked/editor/movies/CompressingWaitingState.go new file mode 100644 index 0000000..bb8ecf4 --- /dev/null +++ b/modding/hacked/editor/movies/CompressingWaitingState.go @@ -0,0 +1,45 @@ +package movies + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ui/gui" +) + +type compressingWaitingState struct { + machine gui.ModalStateMachine + view *View + + input movie.Scene + listener compressionListenerFunc + task *compressionTask +} + +func (state *compressingWaitingState) Render() { + if imgui.BeginPopupModalV("Compressing...", nil, + imgui.WindowFlagsNoResize|imgui.WindowFlagsNoMove|imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) { + imgui.Text("This may take a while.\n" + + "Yes, your PC is probably capable of recoding HD movies way quicker;\n" + + "Sadly, this codec of '94 is quite tricky.") + + if imgui.Button("Cancel") { + state.task.cancel() + } + + result := state.task.update() + if result != nil { + state.machine.SetState(nil) + imgui.CloseCurrentPopup() + + state.listener(result) + } + + imgui.EndPopup() + } else { + state.machine.SetState(nil) + } +} + +func (state *compressingWaitingState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/movies/CompressionTask.go b/modding/hacked/editor/movies/CompressionTask.go new file mode 100644 index 0000000..4fad787 --- /dev/null +++ b/modding/hacked/editor/movies/CompressionTask.go @@ -0,0 +1,65 @@ +package movies + +import ( + "context" + + "github.com/inkyblackness/hacked/ss1/content/movie" +) + +type compressionTask struct { + width int + height int + input movie.Scene + ctx context.Context + ctxCancel context.CancelFunc + resultChan chan compressionResult +} + +type compressionResult interface{} + +type compressionAborted struct{} + +type compressionFailed struct{ err error } + +type compressionFinished struct{ scene movie.HighResScene } + +func newCompressionTask(scene movie.Scene, width, height int) *compressionTask { + task := &compressionTask{ + width: width, + height: height, + input: scene, + resultChan: make(chan compressionResult), + } + task.ctx, task.ctxCancel = context.WithCancel(context.Background()) + + return task +} + +func (task *compressionTask) run() { + defer close(task.resultChan) + highResScene, err := movie.HighResSceneFrom(task.ctx, task.input, task.width, task.height) + switch { + case task.ctx.Err() != nil: + task.resultChan <- compressionAborted{} + case err != nil: + task.resultChan <- compressionFailed{err: err} + default: + task.resultChan <- compressionFinished{scene: highResScene} + } +} + +func (task *compressionTask) update() compressionResult { + select { + case result, ok := <-task.resultChan: + if ok { + return result + } + return compressionAborted{} + default: + return nil + } +} + +func (task *compressionTask) cancel() { + task.ctxCancel() +} diff --git a/modding/hacked/editor/movies/View.go b/modding/hacked/editor/movies/View.go new file mode 100644 index 0000000..76fb0b9 --- /dev/null +++ b/modding/hacked/editor/movies/View.go @@ -0,0 +1,615 @@ +package movies + +import ( + "fmt" + "image" + "image/color" + "image/gif" + "os" + "path/filepath" + "time" + + "github.com/asticode/go-astisub" + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/external" + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/edit/undoable" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/gui" +) + +type movieInfo struct { + title string + multilang bool +} + +var knownMovies = map[resource.ID]movieInfo{ + ids.MovieIntro: {title: "Intro", multilang: true}, + ids.MovieDeath: {title: "Death", multilang: false}, + ids.MovieEnd: {title: "End", multilang: false}, +} + +var knownMoviesOrder = []resource.ID{ids.MovieIntro, ids.MovieDeath, ids.MovieEnd} + +// View provides edit controls for animations. +type View struct { + mod *world.Mod + + frameCache *graphics.FrameCache + frameCacheKey graphics.FrameCacheKey + + movieService undoable.MovieService + + modalStateMachine gui.ModalStateMachine + guiScale float32 + commander cmd.Commander + + model viewModel +} + +// NewMoviesView returns a new instance. +func NewMoviesView(mod *world.Mod, frameCache *graphics.FrameCache, + movieService undoable.MovieService, + modalStateMachine gui.ModalStateMachine, guiScale float32, commander cmd.Commander) *View { + view := &View{ + mod: mod, + + frameCache: frameCache, + frameCacheKey: frameCache.AllocateKey(), + + movieService: movieService, + + modalStateMachine: modalStateMachine, + guiScale: guiScale, + commander: commander, + + model: freshViewModel(), + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *View) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render renders the view. +func (view *View) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + imgui.SetNextWindowSizeV(imgui.Vec2{X: 800 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV("Movies", view.WindowOpen(), imgui.WindowFlagsNoCollapse|imgui.WindowFlagsHorizontalScrollbar) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + if imgui.BeginChildV("Properties", imgui.Vec2{X: 350 * view.guiScale, Y: 0}, false, 0) { + imgui.PushItemWidth(-150 * view.guiScale) + if imgui.BeginCombo("Movie", knownMovies[view.model.currentKey.ID].title) { + for _, id := range knownMoviesOrder { + if imgui.SelectableV(knownMovies[id].title, id == view.model.currentKey.ID, 0, imgui.Vec2{}) { + view.model.currentKey.ID = id + view.model.currentKey.Index = 0 + view.model.currentScene = 0 + view.model.currentFrame = 0 + view.model.frameTimeFraction = -1 + } + } + imgui.EndCombo() + } + + if knownMovies[view.model.currentKey.ID].multilang { + if imgui.BeginCombo("Language", view.model.currentKey.Lang.String()) { + languages := resource.Languages() + for _, lang := range languages { + if imgui.SelectableV(lang.String(), lang == view.model.currentKey.Lang, 0, imgui.Vec2{}) { + view.model.currentKey.Lang = lang + } + } + imgui.EndCombo() + } + } else { + imgui.LabelText("Language", "(not localized)") + } + + imgui.Separator() + + view.renderProperties() + + imgui.PopItemWidth() + } + imgui.EndChild() + + scenes := view.movieService.Video(view.model.currentKey) + imgui.SameLine() + imgui.BeginGroup() + imgui.BeginChildV("Scenes", imgui.Vec2{X: 200 * view.guiScale, Y: -60 * view.guiScale}, true, + imgui.WindowFlagsHorizontalScrollbar|imgui.WindowFlagsAlwaysVerticalScrollbar) + for index, scene := range scenes { + sceneInfo := fmt.Sprintf("Scene %02d (%d frames)", index, len(scene.Frames)) + if imgui.SelectableV(sceneInfo, view.model.currentScene == index, 0, imgui.Vec2{}) { + view.model.currentScene = index + view.model.currentFrame = 0 + view.model.frameTimeFraction = -1 + } + } + imgui.EndChild() + if imgui.Button("Up") { + view.requestMoveSceneEarlier() + } + imgui.SameLine() + if imgui.Button("Down") { + view.requestMoveSceneLater() + } + imgui.SameLine() + if imgui.Button("Remove") { + view.requestRemoveScene() + } + if imgui.Button("Import") { + view.requestImportScene("") + } + imgui.SameLine() + if imgui.Button("Export") { + view.requestExportScene() + } + imgui.EndGroup() + imgui.SameLine() + if imgui.BeginChildV("Frames", imgui.Vec2{X: -1, Y: 0}, false, 0) { + var scene *movie.Scene + if (view.model.currentScene >= 0) && (view.model.currentScene < len(scenes)) { + scene = &scenes[view.model.currentScene] + } + if scene != nil { + var frame *movie.Frame + if (view.model.currentFrame >= 0) && (view.model.currentFrame < len(scene.Frames)) { + frame = &scene.Frames[view.model.currentFrame] + } + if frame != nil { + // This code updates the texture every render cycle. In case of performance loss, this is a point to optimize. + view.frameCache.SetTexture(view.frameCacheKey, 600, 300, frame.Pixels, &scene.Palette) + + render.FrameImage("Frame", view.frameCache, view.frameCacheKey, + imgui.Vec2{ + X: float32(movie.HighResDefaultWidth) * view.guiScale, + Y: float32(movie.HighResDefaultHeight) * view.guiScale, + }) + } + + imgui.PushItemWidth(-150 * view.guiScale) + gui.StepSliderInt("Frame Index", &view.model.currentFrame, 0, len(scene.Frames)-1) + imgui.PopItemWidth() + if (frame != nil) && (frame.DisplayTime < (time.Second / 4)) { + imgui.Separator() + if view.model.frameTimeFraction < 0 { + view.model.frameTimeFraction = int((frame.DisplayTime * 0x10000) / time.Second) + } + imgui.PushItemWidth(-150 * view.guiScale) + gui.StepSliderInt("Frame time fraction", &view.model.frameTimeFraction, 0, 0x3FFF) + imgui.PopItemWidth() + if imgui.Button("Set for scene") { + displayTime := (time.Duration(view.model.frameTimeFraction) * time.Second) / 0x10000 + view.requestSetFramesDisplayTime(displayTime) + } + } + } + } + imgui.EndChild() +} + +func (view *View) renderProperties() { + view.renderAudioProperties() + view.renderSubtitlesProperties() + + if view.movieService.SizeWarning(view.model.currentKey) { + view.renderSizeWarning() + } +} + +func (view *View) renderAudioProperties() { + imgui.PushID("audio") + imgui.Separator() + sound := view.currentSound() + imgui.LabelText("Audio", fmt.Sprintf("%.2f sec", sound.Duration())) + if imgui.Button("Import") { + view.requestImportAudio() + } + if !sound.Empty() { + imgui.SameLine() + if imgui.Button("Export") { + view.requestExportAudio(sound) + } + imgui.SameLine() + if imgui.Button("Clear") { + view.requestClearAudio() + } + } + imgui.PopID() +} + +func (view *View) renderSubtitlesProperties() { + imgui.PushID("subtitles") + imgui.Separator() + if imgui.BeginCombo("Sub Language", view.model.currentSubtitleLang.String()) { + languages := resource.Languages() + for _, lang := range languages { + if imgui.SelectableV(lang.String(), lang == view.model.currentSubtitleLang, 0, imgui.Vec2{}) { + view.model.currentSubtitleLang = lang + } + } + imgui.EndCombo() + } + sub := view.currentSubtitles() + imgui.Text(fmt.Sprintf("%d lines", len(sub.Entries))) + if imgui.Button("Import") { + view.requestImportSubtitles() + } + if len(sub.Entries) > 0 { + imgui.SameLine() + if imgui.Button("Export") { + view.requestExportSubtitles() + } + imgui.SameLine() + if imgui.Button("Clear") { + view.requestClearSubtitles() + } + } + imgui.PopID() +} + +func (view *View) renderSizeWarning() { + imgui.Separator() + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1}) + imgui.Text("Size warning - reduce content!") + imgui.PopStyleColor() + if imgui.IsItemHovered() { + imgui.BeginTooltip() + imgui.SetTooltip("The movie has reached a size larger than officially supported.\n" + + "While the game might still show the movie, a future update may stop supporting it.") + imgui.EndTooltip() + } +} + +func (view *View) restoreFunc() func() { + return view.restoreFuncWithScene(view.model.currentScene) +} + +func (view *View) restoreFuncWithScene(oldScene int) func() { + oldKey := view.model.currentKey + oldSubtitlesLang := view.model.currentSubtitleLang + oldFrame := view.model.currentFrame + + return func() { + view.model.restoreFocus = true + view.model.currentKey = oldKey + view.model.currentSubtitleLang = oldSubtitlesLang + view.model.currentScene = oldScene + view.model.currentFrame = oldFrame + view.model.frameTimeFraction = -1 + } +} + +func (view *View) currentSound() audio.L8 { + return view.movieService.Audio(view.model.currentKey) +} + +func (view *View) requestExportAudio(sound audio.L8) { + filename := fmt.Sprintf("%s_%s.wav", knownMovies[view.model.currentKey.ID].title, view.model.currentKey.Lang.String()) + + external.ExportAudio(view.modalStateMachine, filename, sound) +} + +func (view *View) requestImportAudio() { + external.ImportAudio(view.modalStateMachine, func(sound audio.L8) { + view.movieService.RequestSetAudio(view.model.currentKey, sound, view.restoreFunc()) + }) +} + +func (view *View) requestClearAudio() { + view.movieService.RequestSetAudio(view.model.currentKey, audio.L8{}, view.restoreFunc()) +} + +func (view *View) currentSubtitles() movie.SubtitleList { + return view.movieService.Subtitles(view.model.currentKey, view.model.currentSubtitleLang) +} + +func (view View) requestExportSubtitles() { + filename := fmt.Sprintf("%s_%s.srt", knownMovies[view.model.currentKey.ID].title, view.model.currentSubtitleLang.String()) + info := "File to be written: " + filename + var exportTo func(string) + currentSubtitles := view.currentSubtitles() + + exportTo = func(dirname string) { + writer, err := os.Create(filepath.Join(dirname, filename)) + if err != nil { + external.Export(view.modalStateMachine, "Could not create file.\n"+info, exportTo, true) + return + } + defer func() { _ = writer.Close() }() + + sub := astisub.NewSubtitles() + + var lastItem *astisub.Item + for _, entry := range currentSubtitles.Entries { + var item astisub.Item + var line astisub.Line + line.Items = append(line.Items, astisub.LineItem{Text: entry.Text}) + item.Lines = []astisub.Line{line} + item.StartAt = entry.Timestamp + item.EndAt = item.StartAt + if lastItem != nil { + lastItem.EndAt = item.StartAt + } + lastItem = &item + sub.Items = append(sub.Items, lastItem) + } + + err = sub.WriteToSRT(writer) + if err != nil { + external.Export(view.modalStateMachine, "Could not export subtitles.\n"+info, exportTo, true) + return + } + } + + external.Export(view.modalStateMachine, info, exportTo, false) +} + +func (view *View) requestImportSubtitles() { + info := "File must be an .SRT file." + types := []external.TypeInfo{{Title: "Subtitle files (*.srt)", Extensions: []string{"srt"}}} + var fileHandler func(string) + + fileHandler = func(filename string) { + reader, err := os.Open(filename) + if err != nil { + external.Import(view.modalStateMachine, "Could not open file.\n"+info, types, fileHandler, true) + return + } + defer func() { _ = reader.Close() }() + + subtitles, err := astisub.ReadFromSRT(reader) + if err != nil { + external.Import(view.modalStateMachine, "File not recognized as SRT.\n"+info, types, fileHandler, true) + return + } + var newSubtitles movie.SubtitleList + for _, item := range subtitles.Items { + var newEntry movie.Subtitle + newEntry.Timestamp = item.StartAt + for _, line := range item.Lines { + for _, lineItem := range line.Items { + if len(newEntry.Text) > 0 { + newEntry.Text += "\n" + } + newEntry.Text += lineItem.Text + } + } + newSubtitles.Entries = append(newSubtitles.Entries, newEntry) + } + + view.movieService.RequestSetSubtitles(view.model.currentKey, view.model.currentSubtitleLang, + newSubtitles, view.restoreFunc()) + } + + external.Import(view.modalStateMachine, info, types, fileHandler, false) +} + +func (view *View) requestClearSubtitles() { + view.movieService.RequestSetSubtitles(view.model.currentKey, view.model.currentSubtitleLang, + movie.SubtitleList{}, view.restoreFunc()) +} + +func (view *View) requestImportScene(returningInfo string) { + info := fmt.Sprintf("File must be an animated GIF file in size %dx%d.", + movie.HighResDefaultWidth, movie.HighResDefaultHeight) + types := []external.TypeInfo{{Title: "Animation files (*.gif)", Extensions: []string{"gif"}}} + var fileHandler func(string) + + fileHandler = func(filename string) { + reader, err := os.Open(filename) + if err != nil { + external.Import(view.modalStateMachine, "Could not open file.\n"+info, types, fileHandler, true) + return + } + defer func() { _ = reader.Close() }() + data, err := gif.DecodeAll(reader) + if err != nil { + external.Import(view.modalStateMachine, "File not recognized as GIF.\n"+info, types, fileHandler, true) + return + } + + if (data.Config.Width != movie.HighResDefaultWidth) || (data.Config.Height != movie.HighResDefaultHeight) { + external.Import(view.modalStateMachine, info, types, fileHandler, true) + return + } + + var scene movie.Scene + scene.Frames = make([]movie.Frame, len(data.Image)) + var palette bitmap.Palette + if gifPalette, isPal := data.Config.ColorModel.(color.Palette); isPal { + for index, clr := range gifPalette { + r, g, b, _ := clr.RGBA() + palette[index].Red = byte(r >> 8) + palette[index].Green = byte(g >> 8) + palette[index].Blue = byte(b >> 8) + } + } + framebuffer := make([]byte, data.Config.Width*data.Config.Height) + framebufferSnapshot := func() []byte { + buf := make([]byte, len(framebuffer)) + copy(buf, framebuffer) + return buf + } + + var usedColors [256]bool + for _, img := range data.Image { + for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ { + for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ { + colorIndex := img.ColorIndexAt(x, y) + usedColors[colorIndex] = true + } + } + } + var indexMap [256]byte + for i := 0; i < 256; i++ { + indexMap[byte(i)] = byte(i) + } + // Ensure color index 0 is not used. + if usedColors[0] && (data.BackgroundIndex != 0) { + if data.BackgroundIndex == 255 { + indexMap[0] = palette.IndexClosestTo(palette[0], []byte{0, 255}) + } else { + palette[0], palette[data.BackgroundIndex] = palette[data.BackgroundIndex], palette[0] + indexMap[0] = data.BackgroundIndex + indexMap[data.BackgroundIndex] = 0 + } + } + palette[0] = bitmap.RGB{Red: 0x00, Green: 0x00, Blue: 0x00} // default color for background + // In case the last index is in use by the animation, use a similar color as this entry is reserved for subtitles. + if usedColors[255] && (data.BackgroundIndex != 255) { + indexMap[255] = palette.IndexClosestTo(palette[255], []byte{0, 255}) + } + palette[255] = bitmap.RGB{Red: 0x9A, Green: 0x35, Blue: 0x35} // default color in intro + + scene.Palette = palette + for index, img := range data.Image { + for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ { + for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ { + colorIndex := img.ColorIndexAt(x, y) + if colorIndex != data.BackgroundIndex { + framebuffer[y*data.Config.Width+x] = indexMap[colorIndex] + } + } + } + + scene.Frames[index].DisplayTime = time.Duration(data.Delay[index]) * 10 * time.Millisecond // nolint: durationcheck + scene.Frames[index].Pixels = framebufferSnapshot() + } + + view.compressAndAddScene(scene, data.Config.Width, data.Config.Height) + } + + external.Import(view.modalStateMachine, returningInfo+info, types, fileHandler, false) +} + +func (view *View) compressAndAddScene(scene movie.Scene, width, height int) { + view.modalStateMachine.SetState(&compressingStartState{ + machine: view.modalStateMachine, + view: view, + width: width, + height: height, + input: scene, + listener: view.onCompressionResult, + }) +} + +func (view *View) onCompressionResult(result compressionResult) { + switch typedResult := result.(type) { + case compressionAborted: + case compressionFinished: + view.requestAddScene(typedResult.scene) + case compressionFailed: + view.requestImportScene("Could not compress. Follow recommendations and retry.\n" + + "Technical details:\n" + typedResult.err.Error() + "\n\n") + } +} + +func (view *View) requestAddScene(scene movie.HighResScene) { + view.movieService.RequestAddScene(view.model.currentKey, scene, view.restoreFunc()) +} + +func (view *View) requestExportScene() { + filename := fmt.Sprintf("%s_Scene%02d_%s.gif", + knownMovies[view.model.currentKey.ID].title, + view.model.currentScene, + view.model.currentKey.Lang.String()) + info := "File to be written: " + filename + var exportTo func(string) + + scenes := view.movieService.Video(view.model.currentKey) + var scene *movie.Scene + if view.model.currentScene >= 0 && view.model.currentScene < len(scenes) { + scene = &scenes[view.model.currentScene] + } + + if (scene == nil) || len(scene.Frames) == 0 { + return + } + + exportTo = func(dirname string) { + writer, err := os.Create(filepath.Join(dirname, filename)) + if err != nil { + external.Export(view.modalStateMachine, "Could not create file.\n"+info, exportTo, true) + return + } + defer func() { _ = writer.Close() }() + + colorPalette := scene.Palette.ColorPalette(false) + data := gif.GIF{ + Config: image.Config{ + Width: 600, + Height: 300, + ColorModel: colorPalette, + }, + LoopCount: -1, + } + + imageRect := image.Rect(0, 0, data.Config.Width, data.Config.Height) + for _, frame := range scene.Frames { + frameImg := image.NewPaletted(imageRect, colorPalette) + frameImg.Pix = frame.Pixels + frameImg.Stride = data.Config.Width + data.Image = append(data.Image, frameImg) + data.Delay = append(data.Delay, int(((frame.DisplayTime/time.Millisecond)+5)/10)) + } + + err = gif.EncodeAll(writer, &data) + if err != nil { + external.Export(view.modalStateMachine, info, exportTo, true) + return + } + } + + external.Export(view.modalStateMachine, info, exportTo, false) +} + +func (view *View) requestMoveSceneEarlier() { + scenes := view.movieService.Video(view.model.currentKey) + if (view.model.currentScene > 0) && (view.model.currentScene < len(scenes)) { + view.movieService.RequestMoveSceneEarlier(view.model.currentKey, view.model.currentScene, + view.restoreFuncWithScene(view.model.currentScene-1)) + } +} + +func (view *View) requestMoveSceneLater() { + scenes := view.movieService.Video(view.model.currentKey) + if (view.model.currentScene >= 0) && (view.model.currentScene < (len(scenes) - 1)) { + view.movieService.RequestMoveSceneLater(view.model.currentKey, view.model.currentScene, + view.restoreFuncWithScene(view.model.currentScene+1)) + } +} + +func (view *View) requestRemoveScene() { + scenes := view.movieService.Video(view.model.currentKey) + if (view.model.currentScene >= 0) && (view.model.currentScene < len(scenes)) { + view.movieService.RequestRemoveScene(view.model.currentKey, view.model.currentScene, view.restoreFunc()) + } +} + +func (view *View) requestSetFramesDisplayTime(displayTime time.Duration) { + view.movieService.RequestSetSceneFramesDisplayTime(view.model.currentKey, view.model.currentScene, displayTime, view.restoreFunc()) +} diff --git a/modding/hacked/editor/movies/ViewModel.go b/modding/hacked/editor/movies/ViewModel.go new file mode 100644 index 0000000..e8bfc81 --- /dev/null +++ b/modding/hacked/editor/movies/ViewModel.go @@ -0,0 +1,26 @@ +package movies + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +type viewModel struct { + windowOpen bool + restoreFocus bool + + currentKey resource.Key + currentSubtitleLang resource.Language + currentScene int + currentFrame int + + frameTimeFraction int +} + +func freshViewModel() viewModel { + return viewModel{ + currentKey: resource.KeyOf(ids.MovieIntro, resource.LangDefault, 0), + currentSubtitleLang: resource.LangDefault, + frameTimeFraction: -1, + } +} diff --git a/modding/hacked/editor/objects/SetObjectBitmapCommand.go b/modding/hacked/editor/objects/SetObjectBitmapCommand.go new file mode 100644 index 0000000..2d63af6 --- /dev/null +++ b/modding/hacked/editor/objects/SetObjectBitmapCommand.go @@ -0,0 +1,35 @@ +package objects + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +type setObjectBitmapCommand struct { + model *viewModel + + triple object.Triple + bitmap int + + resourceKey resource.Key + oldData []byte + newData []byte +} + +func (command setObjectBitmapCommand) Do(modder world.Modder) error { + return command.perform(modder, command.newData) +} + +func (command setObjectBitmapCommand) Undo(modder world.Modder) error { + return command.perform(modder, command.oldData) +} + +func (command setObjectBitmapCommand) perform(modder world.Modder, data []byte) error { + modder.SetResourceBlock(command.resourceKey.Lang, command.resourceKey.ID, command.resourceKey.Index, data) + + command.model.restoreFocus = true + command.model.currentObject = command.triple + command.model.currentBitmap = command.bitmap + return nil +} diff --git a/modding/hacked/editor/objects/SetObjectPropertiesCommand.go b/modding/hacked/editor/objects/SetObjectPropertiesCommand.go new file mode 100644 index 0000000..ba66919 --- /dev/null +++ b/modding/hacked/editor/objects/SetObjectPropertiesCommand.go @@ -0,0 +1,33 @@ +package objects + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/world" +) + +type setObjectPropertiesCommand struct { + model *viewModel + + triple object.Triple + bitmap int + + oldProperties object.Properties + newProperties object.Properties +} + +func (command setObjectPropertiesCommand) Do(modder world.Modder) error { + return command.perform(modder, command.newProperties) +} + +func (command setObjectPropertiesCommand) Undo(modder world.Modder) error { + return command.perform(modder, command.oldProperties) +} + +func (command setObjectPropertiesCommand) perform(modder world.Modder, properties object.Properties) error { + modder.SetObjectProperties(command.triple, properties) + + command.model.restoreFocus = true + command.model.currentObject = command.triple + command.model.currentBitmap = command.bitmap + return nil +} diff --git a/modding/hacked/editor/objects/SetObjectTextCommand.go b/modding/hacked/editor/objects/SetObjectTextCommand.go new file mode 100644 index 0000000..c3f7566 --- /dev/null +++ b/modding/hacked/editor/objects/SetObjectTextCommand.go @@ -0,0 +1,35 @@ +package objects + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +type setObjectTextCommand struct { + model *viewModel + + triple object.Triple + bitmap int + key resource.Key + + oldData []byte + newData []byte +} + +func (command setObjectTextCommand) Do(modder world.Modder) error { + return command.perform(modder, command.newData) +} + +func (command setObjectTextCommand) Undo(modder world.Modder) error { + return command.perform(modder, command.oldData) +} + +func (command setObjectTextCommand) perform(modder world.Modder, data []byte) error { + modder.SetResourceBlock(command.key.Lang, command.key.ID, command.key.Index, data) + command.model.restoreFocus = true + command.model.currentObject = command.triple + command.model.currentBitmap = command.bitmap + command.model.currentLang = command.key.Lang + return nil +} diff --git a/modding/hacked/editor/objects/View.go b/modding/hacked/editor/objects/View.go new file mode 100644 index 0000000..78f6755 --- /dev/null +++ b/modding/hacked/editor/objects/View.go @@ -0,0 +1,710 @@ +package objects + +import ( + "fmt" + "strings" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/external" + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/editor/values" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/object/objprop" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/gui" +) + +// View provides edit controls for game objects. +type View struct { + mod *world.Mod + textCache *text.Cache + cp text.Codepage + imageCache *graphics.TextureCache + paletteCache *graphics.PaletteCache + + modalStateMachine gui.ModalStateMachine + clipboard external.Clipboard + guiScale float32 + commander cmd.Commander + + model viewModel +} + +// NewView returns a new instance. +func NewView(mod *world.Mod, textCache *text.Cache, cp text.Codepage, + imageCache *graphics.TextureCache, paletteCache *graphics.PaletteCache, + modalStateMachine gui.ModalStateMachine, + clipboard external.Clipboard, guiScale float32, commander cmd.Commander) *View { + view := &View{ + mod: mod, + textCache: textCache, + cp: cp, + imageCache: imageCache, + paletteCache: paletteCache, + + modalStateMachine: modalStateMachine, + clipboard: clipboard, + guiScale: guiScale, + commander: commander, + + model: freshViewModel(), + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *View) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render renders the view. +func (view *View) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + imgui.SetNextWindowSizeV(imgui.Vec2{X: 800 * view.guiScale, Y: 600 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV("Game Objects", view.WindowOpen(), imgui.WindowFlagsNoCollapse|imgui.WindowFlagsHorizontalScrollbar) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + if imgui.BeginChildV("Properties", imgui.Vec2{X: -330 * view.guiScale, Y: 0}, false, imgui.WindowFlagsHorizontalScrollbar) { + imgui.PushItemWidth(-260 * view.guiScale) + classString := func(class object.Class) string { + return fmt.Sprintf("%2d: %v", int(class), class) + } + if imgui.BeginCombo("Object Class", classString(view.model.currentObject.Class)) { + for _, class := range object.Classes() { + if imgui.SelectableV(classString(class), class == view.model.currentObject.Class, 0, imgui.Vec2{}) { + view.model.currentObject = object.TripleFrom(int(class), 0, 0) + view.model.currentBitmap = 0 + } + } + imgui.EndCombo() + } + if imgui.BeginCombo("Object Type", view.tripleName(view.model.currentObject)) { + allTypes := view.mod.ObjectProperties().TriplesInClass(view.model.currentObject.Class) + for _, triple := range allTypes { + if imgui.SelectableV(view.tripleName(triple), triple == view.model.currentObject, 0, imgui.Vec2{}) { + view.model.currentObject = triple + view.model.currentBitmap = 0 + } + } + imgui.EndCombo() + } + + readOnly := !view.mod.HasModifiableObjectProperties() + properties, propErr := view.mod.ObjectProperties().ForObject(view.model.currentObject) + + imgui.Separator() + bitmapLimit := 0 + if propErr == nil { + bitmapLimit = int(properties.Common.Bitmap3D.FrameNumber() + 2) + } + gui.StepSliderInt("Bitmap", &view.model.currentBitmap, 0, bitmapLimit) + render.TextureSelector("BitmapSelector", -1, view.guiScale, bitmapLimit+1, view.model.currentBitmap, + view.imageCache, view.currentBitmapKeyFor, func(int) string { return "" }, func(newValue int) { + view.model.currentBitmap = newValue + }) + + imgui.Separator() + + if imgui.BeginCombo("Language", view.model.currentLang.String()) { + languages := resource.Languages() + for _, lang := range languages { + if imgui.SelectableV(lang.String(), lang == view.model.currentLang, 0, imgui.Vec2{}) { + view.model.currentLang = lang + } + } + imgui.EndCombo() + } + view.renderText(false, "Long Name", + view.objectName(view.model.currentObject, view.model.currentLang, true), + func(newValue string) { + view.requestSetObjectName(view.model.currentObject, true, newValue) + }) + view.renderText(false, "Short Name", + view.objectName(view.model.currentObject, view.model.currentLang, false), + func(newValue string) { + view.requestSetObjectName(view.model.currentObject, false, newValue) + }) + + if propErr == nil { + if imgui.TreeNodeV("Common Properties", imgui.TreeNodeFlagsDefaultOpen|imgui.TreeNodeFlagsFramed) { + view.renderCommonProperties(readOnly, properties) + imgui.TreePop() + } + if imgui.TreeNodeV("Generic Properties", imgui.TreeNodeFlagsFramed) { + view.renderGenericProperties(readOnly, properties) + imgui.TreePop() + } + if imgui.TreeNodeV("Specific Properties", imgui.TreeNodeFlagsFramed) { + view.renderSpecificProperties(readOnly, properties) + imgui.TreePop() + } + } else { + imgui.Text("(properties unavailable)") + } + + imgui.PopItemWidth() + } + imgui.EndChild() + imgui.SameLine() + + imgui.BeginGroup() + view.renderObjectBitmap() + imgui.EndGroup() +} + +func (view *View) renderText(readOnly bool, label string, value string, changeCallback func(string)) { + imgui.LabelText(label, value) + view.clipboardPopup(readOnly, label, value, changeCallback) +} + +func (view *View) tripleName(triple object.Triple) string { + return triple.String() + ": " + view.objectName(triple, resource.LangDefault, true) +} + +func (view *View) objectName(triple object.Triple, lang resource.Language, longName bool) string { + result := "???" + linearIndex := view.mod.ObjectProperties().TripleIndex(triple) + if linearIndex >= 0 { + nameID := ids.ObjectShortNames + if longName { + nameID = ids.ObjectLongNames + } + key := resource.KeyOf(nameID, lang, linearIndex) + objName, err := view.textCache.Text(key) + if err == nil { + result = objName + } + } + return result +} + +func (view *View) clipboardPopup(readOnly bool, label string, value string, changeCallback func(string)) { + if imgui.BeginPopupContextItemV(label+"-Popup", 1) { + if imgui.Selectable("Copy to Clipboard") { + view.clipboard.SetString(value) + } + if !readOnly && imgui.Selectable("Copy from Clipboard") { + newValue, err := view.clipboard.String() + if err == nil { + changeCallback(newValue) + } + } + imgui.EndPopup() + } +} + +func (view *View) requestSetObjectName(triple object.Triple, longName bool, newValue string) { + linearIndex := view.mod.ObjectProperties().TripleIndex(triple) + if linearIndex >= 0 { + id := ids.ObjectShortNames + if longName { + id = ids.ObjectLongNames + } + key := resource.KeyOf(id, view.model.currentLang, linearIndex) + oldValue, _ := view.textCache.Text(key) + + if oldValue != newValue { + command := setObjectTextCommand{ + model: &view.model, + triple: view.model.currentObject, + bitmap: view.model.currentBitmap, + key: key, + oldData: view.cp.Encode(oldValue), + newData: view.cp.Encode(text.Blocked(newValue)[0]), + } + view.commander.Queue(command) + } + } +} + +func (view *View) requestSetObjectProperties(modifier func(*object.Properties)) { + command := setObjectPropertiesCommand{ + model: &view.model, + triple: view.model.currentObject, + bitmap: view.model.currentBitmap, + } + currentProp, err := view.mod.ObjectProperties().ForObject(command.triple) + if err != nil { + return + } + command.oldProperties = currentProp.Clone() + command.newProperties = currentProp.Clone() + modifier(&command.newProperties) + view.commander.Queue(command) +} + +func (view *View) renderCommonProperties(readOnly bool, properties *object.Properties) { + intIdentity := func(u values.Unifier) int { return u.Unified().(int) } + intFormat := func(value int) string { return "%d" } + + if imgui.TreeNode("Flags") { + renderFlag := func(flag object.CommonFlag) { + flagUnifier := values.NewUnifier() + flagUnifier.Add(properties.Common.Flags.Has(flag)) + values.RenderUnifiedCheckboxCombo(readOnly, flag.String(), flagUnifier, func(newValue bool) { + view.requestSetObjectProperties(func(prop *object.Properties) { + if newValue { + prop.Common.Flags = prop.Common.Flags.With(flag) + } else { + prop.Common.Flags = prop.Common.Flags.Without(flag) + } + }) + }) + } + for _, flag := range object.CommonFlags() { + renderFlag(flag) + } + + imgui.TreePop() + } + + lightTypes := object.LightTypes() + lightTypeUnifier := values.NewUnifier() + lightTypeUnifier.Add(int(properties.Common.Flags.LightType())) + values.RenderUnifiedCombo(readOnly, "LightType", lightTypeUnifier, intIdentity, + func(value int) string { + return object.LightType(value).String() + }, len(lightTypes), func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.Flags = prop.Common.Flags.WithLightType(object.LightType(newValue)) + }) + }) + + useModeUnifier := values.NewUnifier() + useModeUnifier.Add(int(properties.Common.Flags.UseMode())) + values.RenderUnifiedCombo(readOnly, "UseMode", useModeUnifier, intIdentity, + func(value int) string { + return object.UseMode(value).String() + }, 4, func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.Flags = prop.Common.Flags.WithUseMode(object.UseMode(newValue)) + }) + }) + + massUnifier := values.NewUnifier() + massUnifier.Add(int(properties.Common.Mass)) + values.RenderUnifiedSliderInt(readOnly, "Mass", massUnifier, intIdentity, intFormat, -1, 5000, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.Mass = int32(newValue) + }) + }) + + hitpointsUnifier := values.NewUnifier() + hitpointsUnifier.Add(int(properties.Common.Hitpoints)) + values.RenderUnifiedSliderInt(readOnly, "Hitpoints", hitpointsUnifier, intIdentity, intFormat, 0, 10000, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.Hitpoints = int16(newValue) + }) + }) + + armorUnifier := values.NewUnifier() + armorUnifier.Add(int(properties.Common.Armor)) + values.RenderUnifiedSliderInt(readOnly, "Armor", armorUnifier, intIdentity, intFormat, 0, 255, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.Armor = byte(newValue) + }) + }) + + renderTypeUnifier := values.NewUnifier() + renderTypeUnifier.Add(int(properties.Common.RenderType)) + values.RenderUnifiedCombo(readOnly, "Render Type", renderTypeUnifier, intIdentity, + func(value int) string { return object.RenderType(value).String() }, + len(object.RenderTypes()), func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.RenderType = object.RenderType(newValue) + }) + }) + + physicsModelUnifier := values.NewUnifier() + physicsModelUnifier.Add(int(properties.Common.PhysicsModel)) + values.RenderUnifiedCombo(readOnly, "Physics Model", physicsModelUnifier, intIdentity, + func(value int) string { return object.PhysicsModel(value).String() }, + len(object.PhysicsModels()), func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.PhysicsModel = object.PhysicsModel(newValue) + }) + }) + + hardnessUnifier := values.NewUnifier() + hardnessUnifier.Add(int(properties.Common.Hardness)) + values.RenderUnifiedSliderInt(readOnly, "Hardness", hardnessUnifier, intIdentity, intFormat, 0, object.HardnessLimit, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.Hardness = byte(newValue) + }) + }) + + physicsXRUnifier := values.NewUnifier() + physicsXRUnifier.Add(int(properties.Common.PhysicsXR)) + values.RenderUnifiedSliderInt(readOnly, "Physics XR", physicsXRUnifier, intIdentity, intFormat, 0, object.PhysicsXRLimit, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.PhysicsXR = byte(newValue) + }) + }) + + physicsZUnifier := values.NewUnifier() + physicsZUnifier.Add(int(properties.Common.PhysicsZ)) + values.RenderUnifiedSliderInt(readOnly, "Physics Z", physicsZUnifier, intIdentity, intFormat, 0, 255, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.PhysicsZ = byte(newValue) + }) + }) + + if imgui.TreeNode("Vulnerabilities") { + renderVulnerability := func(damageType object.DamageType) { + damageUnifier := values.NewUnifier() + damageUnifier.Add(properties.Common.Vulnerabilities.Has(damageType)) + values.RenderUnifiedCheckboxCombo(readOnly, damageType.String(), damageUnifier, func(newValue bool) { + view.requestSetObjectProperties(func(prop *object.Properties) { + if newValue { + prop.Common.Vulnerabilities = prop.Common.Vulnerabilities.With(damageType) + } else { + prop.Common.Vulnerabilities = prop.Common.Vulnerabilities.Without(damageType) + } + }) + }) + } + for _, damageType := range object.DamageTypes() { + renderVulnerability(damageType) + } + + primaryUnifier := values.NewUnifier() + primaryUnifier.Add(properties.Common.SpecialVulnerabilities.PrimaryValue()) + values.RenderUnifiedSliderInt(readOnly, "Primary (double dmg)", primaryUnifier, intIdentity, intFormat, 0, object.SpecialDamageTypeLimit, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.SpecialVulnerabilities = prop.Common.SpecialVulnerabilities.WithPrimaryValue(newValue) + }) + }) + + superUnifier := values.NewUnifier() + superUnifier.Add(properties.Common.SpecialVulnerabilities.SuperValue()) + values.RenderUnifiedSliderInt(readOnly, "Super (quad dmg)", superUnifier, intIdentity, intFormat, 0, object.SpecialDamageTypeLimit, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.SpecialVulnerabilities = prop.Common.SpecialVulnerabilities.WithSuperValue(newValue) + }) + }) + + imgui.TreePop() + } + + defenseUnifier := values.NewUnifier() + defenseUnifier.Add(int(properties.Common.Defense)) + values.RenderUnifiedSliderInt(readOnly, "Defense", defenseUnifier, intIdentity, + func(value int) string { + if value == object.DefenseNoCriticals { + return "%d - (no criticals)" + } + return "%d" + }, 0, 255, + func(newValue int) { + view.requestSetObjectProperties(func(prop *object.Properties) { + prop.Common.Defense = byte(newValue) + }) + }) + + toughnessUnifier := values.NewUnifier() + toughnessUnifier.Add(int(properties.Common.Toughness)) + values.RenderUnifiedSliderInt(readOnly, "Toughness", toughnessUnifier, intIdentity, + func(value int) string { + if value == object.ToughnessNoDamage { + return "(no damage) -- raw: %d" + } + return fmt.Sprintf("%d:1 dmg -- raw: %%d", 1< 0 { + imgui.Separator() + imgui.Text(fullKey + ":") + } + processInterpreter(fullKey+".", interpreter.Refined(key)) + } + } + processInterpreter("", rootInterpreter) +} + +func (view *View) setInterpreterValueKeyed(instance *interpreters.Instance, key string, modifier func(uint32) uint32) { + resolvedInterpreter := instance + keys := strings.Split(key, ".") + keyCount := len(keys) + if len(keys) > 1 { + for _, subKey := range keys[:keyCount-1] { + resolvedInterpreter = resolvedInterpreter.Refined(subKey) + } + } + resolvedInterpreter.Set(keys[keyCount-1], modifier(resolvedInterpreter.Get(keys[keyCount-1]))) +} + +func (view *View) renderObjectBitmap() { + render.TextureImage("BitmapImage", view.imageCache, view.currentBitmapKey(), imgui.Vec2{X: 320 * view.guiScale, Y: 240 * view.guiScale}) + if imgui.Button("Clear") { + view.requestClearBitmap() + } + imgui.SameLine() + if imgui.Button("Import") { + view.requestImportBitmap() + } + imgui.SameLine() + if imgui.Button("Export") { + view.requestExportBitmap() + } + if view.hasModCurrentBitmap() { + imgui.SameLine() + if imgui.Button("Remove") { + view.requestSetBitmapData(nil) + } + } +} + +func (view *View) requestClearBitmap() { + bmp := bitmap.Bitmap{ + Header: bitmap.Header{ + Width: 1, + Height: 1, + }, + Pixels: []byte{0x00}, + } + view.requestSetBitmap(bmp) +} + +func (view *View) requestImportBitmap() { + paletteRetriever := func() (bitmap.Palette, error) { + palette, err := view.paletteCache.Palette(0) + if err != nil { + return bitmap.Palette{}, err + } + return palette.Palette(), nil + } + external.ImportImage(view.modalStateMachine, paletteRetriever, func(bmp bitmap.Bitmap) { + view.requestSetBitmap(bmp) + }) +} + +func (view *View) requestExportBitmap() { + key := view.currentBitmapKey() + texture, err := view.imageCache.Texture(key) + if err != nil { + return + } + palette, err := view.paletteCache.Palette(0) + if err != nil { + return + } + rawPalette := palette.Palette() + filename := fmt.Sprintf("%02d_%d_%02d-%02d.png", + view.model.currentObject.Class, view.model.currentObject.Subclass, view.model.currentObject.Type, view.model.currentBitmap) + width, height := texture.Size() + bmp := bitmap.Bitmap{ + Header: bitmap.Header{ + Width: int16(width), + Height: int16(height), + }, + Pixels: texture.PixelData(), + Palette: &rawPalette, + } + + external.ExportImage(view.modalStateMachine, filename, bmp) +} + +func (view *View) hasModCurrentBitmap() bool { + key := view.currentBitmapKey() + return len(view.mod.ModifiedBlock(key.Lang, key.ID, key.Index)) > 0 +} + +func (view *View) requestSetBitmap(bmp bitmap.Bitmap) { + highestBitShift := func(value int16) (result byte) { + if value != 0 { + for (value >> result) != 1 { + result++ + } + } + return + } + + bmp.Header.Flags = bitmap.FlagTransparent + bmp.Header.Type = bitmap.TypeFlat8Bit + bmp.Header.WidthFactor = highestBitShift(bmp.Header.Width) + bmp.Header.HeightFactor = highestBitShift(bmp.Header.Height) + bmp.Header.Stride = uint16(bmp.Header.Width) + data := bitmap.Encode(&bmp, 0) + view.requestSetBitmapData(data) +} + +func (view *View) requestSetBitmapData(newData []byte) { + resourceKey := view.currentBitmapKey() + + command := setObjectBitmapCommand{ + model: &view.model, + triple: view.model.currentObject, + bitmap: view.model.currentBitmap, + + resourceKey: resourceKey, + oldData: view.mod.ModifiedBlock(resourceKey.Lang, resourceKey.ID, resourceKey.Index), + newData: newData, + } + view.commander.Queue(command) +} + +func (view *View) currentBitmapKey() resource.Key { + return view.currentBitmapKeyFor(view.model.currentBitmap) +} + +func (view *View) currentBitmapKeyFor(offset int) resource.Key { + baseOffset := 1 + done := false + view.mod.ObjectProperties().Iterate(func(triple object.Triple, prop *object.Properties) bool { + if !done && triple == view.model.currentObject { + done = true + } + if done { + return false + } + numExtra := int(prop.Common.Bitmap3D.FrameNumber()) + baseOffset += 3 + numExtra + return true + }) + + return resource.KeyOf(ids.ObjectBitmaps, resource.LangAny, baseOffset+offset) +} diff --git a/modding/hacked/editor/objects/ViewModel.go b/modding/hacked/editor/objects/ViewModel.go new file mode 100644 index 0000000..f54b660 --- /dev/null +++ b/modding/hacked/editor/objects/ViewModel.go @@ -0,0 +1,19 @@ +package objects + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/resource" +) + +type viewModel struct { + windowOpen bool + restoreFocus bool + + currentObject object.Triple + currentBitmap int + currentLang resource.Language +} + +func freshViewModel() viewModel { + return viewModel{} +} diff --git a/modding/hacked/editor/project/AddManifestEntryStartState.go b/modding/hacked/editor/project/AddManifestEntryStartState.go new file mode 100644 index 0000000..cb42d69 --- /dev/null +++ b/modding/hacked/editor/project/AddManifestEntryStartState.go @@ -0,0 +1,23 @@ +package project + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type addManifestEntryStartState struct { + machine gui.ModalStateMachine + view *View +} + +func (state addManifestEntryStartState) Render() { + imgui.OpenPopup("Add static world data") + state.machine.SetState(&addManifestEntryWaitingState{ + machine: state.machine, + view: state.view, + }) +} + +func (state addManifestEntryStartState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/project/AddManifestEntryWaitingState.go b/modding/hacked/editor/project/AddManifestEntryWaitingState.go new file mode 100644 index 0000000..e5f039f --- /dev/null +++ b/modding/hacked/editor/project/AddManifestEntryWaitingState.go @@ -0,0 +1,61 @@ +package project + +import ( + "time" + + "github.com/inkyblackness/imgui-go/v3" + "github.com/sqweek/dialog" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type addManifestEntryWaitingState struct { + machine gui.ModalStateMachine + view *View + failureTime time.Time +} + +func (state *addManifestEntryWaitingState) Render() { + if imgui.BeginPopupModalV("Add static world data", nil, + imgui.WindowFlagsNoResize|imgui.WindowFlagsNoMove|imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) { + imgui.Text("Waiting for folders/files.") + if !state.failureTime.IsZero() { + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1}) + imgui.Text("Previous attempt failed, no usable data detected.\nPlease check and try again.") + imgui.PopStyleColor() + if time.Since(state.failureTime).Seconds() > 5 { + state.failureTime = time.Time{} + } + } + imgui.Text(`From your file browser drag'n'drop the folder (or files) +of the static data you want to reference into the editor window. +Typically, you would use the main "data" directory of the game +(where all the .res files are). +`) + imgui.Separator() + if imgui.Button("Browse...") { + dlgBuilder := dialog.Directory() + filename, err := dlgBuilder.Browse() + if err == nil { + state.HandleFiles([]string{filename}) + } + } + imgui.SameLine() + if imgui.Button("Cancel") { + state.machine.SetState(nil) + imgui.CloseCurrentPopup() + } + imgui.EndPopup() + } else { + state.machine.SetState(nil) + } +} + +func (state *addManifestEntryWaitingState) HandleFiles(names []string) { + err := state.view.tryAddManifestEntryFrom(names) + if err != nil { + state.failureTime = time.Now() + } else { + state.machine.SetState(nil) + } +} diff --git a/modding/hacked/editor/project/LoadModStartState.go b/modding/hacked/editor/project/LoadModStartState.go new file mode 100644 index 0000000..822ade3 --- /dev/null +++ b/modding/hacked/editor/project/LoadModStartState.go @@ -0,0 +1,23 @@ +package project + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type loadModStartState struct { + machine gui.ModalStateMachine + view *View +} + +func (state loadModStartState) Render() { + imgui.OpenPopup("Load mod") + state.machine.SetState(&loadModWaitingState{ + machine: state.machine, + view: state.view, + }) +} + +func (state loadModStartState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/project/LoadModWaitingState.go b/modding/hacked/editor/project/LoadModWaitingState.go new file mode 100644 index 0000000..872aa82 --- /dev/null +++ b/modding/hacked/editor/project/LoadModWaitingState.go @@ -0,0 +1,62 @@ +package project + +import ( + "time" + + "github.com/inkyblackness/imgui-go/v3" + "github.com/sqweek/dialog" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type loadModWaitingState struct { + machine gui.ModalStateMachine + view *View + failureTime time.Time +} + +func (state *loadModWaitingState) Render() { + if imgui.BeginPopupModalV("Load mod", nil, + imgui.WindowFlagsNoResize|imgui.WindowFlagsNoMove|imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) { + imgui.Text("Waiting for folder.") + if !state.failureTime.IsZero() { + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1}) + imgui.Text("Previous attempt failed, no usable data detected.\nPlease check and try again.") + imgui.PopStyleColor() + if time.Since(state.failureTime).Seconds() > 5 { + state.failureTime = time.Time{} + } + } + imgui.Text(`From your file browser drag'n'drop the folder +of the mod you want to work on into the editor window. +If you want to modify the main game files, +use the main "data" directory of the game. +`) + imgui.Text("This action will clear the undo/redo buffer\nand you will lose any unsaved changes.") + imgui.Separator() + if imgui.Button("Browse...") { + dlgBuilder := dialog.Directory() + filename, err := dlgBuilder.Browse() + if err == nil { + state.HandleFiles([]string{filename}) + } + } + imgui.SameLine() + if imgui.Button("Cancel") { + state.machine.SetState(nil) + imgui.CloseCurrentPopup() + } + imgui.EndPopup() + } else { + state.machine.SetState(nil) + } +} + +func (state *loadModWaitingState) HandleFiles(names []string) { + err := state.view.tryLoadModFrom(names) + if err != nil { + state.failureTime = time.Now() + } else { + state.machine.SetState(nil) + } +} diff --git a/modding/hacked/editor/project/SaveModAsStartState.go b/modding/hacked/editor/project/SaveModAsStartState.go new file mode 100644 index 0000000..9369b43 --- /dev/null +++ b/modding/hacked/editor/project/SaveModAsStartState.go @@ -0,0 +1,23 @@ +package project + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type saveModAsStartState struct { + machine gui.ModalStateMachine + view *View +} + +func (state saveModAsStartState) Render() { + imgui.OpenPopup("Save mod as") + state.machine.SetState(&saveModAsWaitingState{ + machine: state.machine, + view: state.view, + }) +} + +func (state saveModAsStartState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/project/SaveModAsWaitingState.go b/modding/hacked/editor/project/SaveModAsWaitingState.go new file mode 100644 index 0000000..19402bf --- /dev/null +++ b/modding/hacked/editor/project/SaveModAsWaitingState.go @@ -0,0 +1,82 @@ +package project + +import ( + "os" + "time" + + "github.com/inkyblackness/imgui-go/v3" + "github.com/sqweek/dialog" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type saveModAsWaitingState struct { + machine gui.ModalStateMachine + view *View + failureTime time.Time + errorInfo string +} + +func (state *saveModAsWaitingState) Render() { + if imgui.BeginPopupModalV("Save mod as", nil, + imgui.WindowFlagsNoResize|imgui.WindowFlagsNoMove|imgui.WindowFlagsNoSavedSettings|imgui.WindowFlagsAlwaysAutoResize) { + imgui.Text("Waiting for folder.") + if !state.failureTime.IsZero() { + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1}) + imgui.Text("Previous attempt failed, not a proper folder.\nPlease check and try again.") + if len(state.errorInfo) > 0 { + imgui.Text(state.errorInfo) + } + imgui.PopStyleColor() + if time.Since(state.failureTime).Seconds() > 5 { + state.failureTime = time.Time{} + } + } + imgui.Text(`From your file browser drag'n'drop the folder +where to save the data into the editor window. +`) + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1, Y: 0, Z: 0, W: 1}) + imgui.Text("It is recommended to use an empty folder.\nSaving will potentially overwrite existing files.") + imgui.PopStyleColor() + imgui.Separator() + if imgui.Button("Browse...") { + dlgBuilder := dialog.Directory() + filename, err := dlgBuilder.Browse() + if err == nil { + state.HandleFiles([]string{filename}) + } + } + imgui.SameLine() + if imgui.Button("Cancel") { + state.machine.SetState(nil) + imgui.CloseCurrentPopup() + } + imgui.EndPopup() + } else { + state.machine.SetState(nil) + } +} + +func (state *saveModAsWaitingState) HandleFiles(names []string) { + modPath, ok := state.verifyDir(names) + if ok { + state.machine.SetState(nil) + state.view.requestSaveMod(modPath) + } else { + state.failureTime = time.Now() + } +} + +func (state saveModAsWaitingState) verifyDir(names []string) (string, bool) { + if len(names) != 1 { + return "", false + } + fileInfo, err := os.Stat(names[0]) + if err != nil { + return "", false + } + if !fileInfo.IsDir() { + return "", false + } + return names[0], true +} diff --git a/modding/hacked/editor/project/SaveModFailedState.go b/modding/hacked/editor/project/SaveModFailedState.go new file mode 100644 index 0000000..228ab47 --- /dev/null +++ b/modding/hacked/editor/project/SaveModFailedState.go @@ -0,0 +1,28 @@ +package project + +import ( + "time" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/gui" +) + +type saveModFailedState struct { + machine gui.ModalStateMachine + view *View + errorInfo string +} + +func (state saveModFailedState) Render() { + imgui.OpenPopup("Save mod as") + state.machine.SetState(&saveModAsWaitingState{ + machine: state.machine, + view: state.view, + failureTime: time.Now(), + errorInfo: state.errorInfo, + }) +} + +func (state saveModFailedState) HandleFiles(names []string) { +} diff --git a/modding/hacked/editor/project/View.go b/modding/hacked/editor/project/View.go new file mode 100644 index 0000000..4fc8c31 --- /dev/null +++ b/modding/hacked/editor/project/View.go @@ -0,0 +1,246 @@ +package project + +import ( + "fmt" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ui/gui" +) + +// View handles the project display. +type View struct { + service *edit.ProjectService + + modalStateMachine gui.ModalStateMachine + guiScale float32 + commander cmd.Registry + + model viewModel +} + +// NewView creates a new instance for the project display. +func NewView(service *edit.ProjectService, modalStateMachine gui.ModalStateMachine, guiScale float32, commander cmd.Registry) *View { + return &View{ + service: service, + + modalStateMachine: modalStateMachine, + guiScale: guiScale, + commander: commander, + + model: freshViewModel(), + } +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *View) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render requests to render the view. +func (view *View) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + title := "Project" + saveStatus := view.service.SaveStatus() + if saveStatus.FilesModified > 0 { + title += fmt.Sprintf(" - %d file(s) pending save", saveStatus.FilesModified) + if saveStatus.SavePending && (saveStatus.SaveIn.Seconds() < 4) { + title += fmt.Sprintf(" - auto-save in %d", int(saveStatus.SaveIn.Seconds())) + } + } + if view.model.windowOpen { + imgui.SetNextWindowSizeV(imgui.Vec2{X: 400 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV(title+"###Project", view.WindowOpen(), 0) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + imgui.Text("Mod Location") + imgui.PushStyleVarVec2(imgui.StyleVarWindowPadding, imgui.Vec2{X: 1, Y: 0}) + imgui.BeginChildV("ModLocation", imgui.Vec2{X: -200*view.guiScale - 10*view.guiScale, Y: imgui.TextLineHeight() * 1.5}, true, + imgui.WindowFlagsNoScrollbar|imgui.WindowFlagsNoScrollWithMouse) + modPath := view.service.ModPath() + if len(modPath) > 0 { + imgui.Text(modPath) + } else { + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1.0, Y: 1.0, Z: 1.0, W: 0.5}) + imgui.Text("(new mod)") + imgui.PopStyleColor() + } + imgui.EndChild() + imgui.PopStyleVar() + imgui.BeginGroup() + imgui.SameLine() + if imgui.ButtonV("Save", imgui.Vec2{X: 100 * view.guiScale, Y: 0}) { + view.StartSavingMod() + } + imgui.SameLine() + if imgui.ButtonV("Load...", imgui.Vec2{X: 100 * view.guiScale, Y: 0}) { + view.startLoadingMod() + } + imgui.EndGroup() + + imgui.Text("Static World Data") + imgui.BeginChildV("ManifestEntries", imgui.Vec2{X: -100 * view.guiScale, Y: 0}, true, 0) + manifest := view.service.Mod().World() + entries := manifest.EntryCount() + for i := entries - 1; i >= 0; i-- { + entry, _ := manifest.Entry(i) + if imgui.SelectableV(entry.ID, view.model.selectedManifestEntry == i, 0, imgui.Vec2{}) { + view.model.selectedManifestEntry = i + } + } + imgui.EndChild() + imgui.SameLine() + imgui.BeginGroup() + if imgui.ButtonV("Add...", imgui.Vec2{X: -1, Y: 0}) { + view.startAddingManifestEntry() + } + if imgui.ButtonV("Up", imgui.Vec2{X: -1, Y: 0}) { + view.requestMoveManifestEntryUp() + } + if imgui.ButtonV("Down", imgui.Vec2{X: -1, Y: 0}) { + view.requestMoveManifestEntryDown() + } + if imgui.ButtonV("Remove", imgui.Vec2{X: -1, Y: 0}) { + view.requestRemoveManifestEntry() + } + imgui.EndGroup() +} + +func (view *View) startLoadingMod() { + view.modalStateMachine.SetState(&loadModStartState{ + machine: view.modalStateMachine, + view: view, + }) +} + +// StartSavingMod initiates to save the mod. +// It either opens the save-as dialog, or simply saves under the current folder. +func (view *View) StartSavingMod() { + if view.service.ModHasStorageLocation() { + err := view.service.SaveMod() + if err != nil { + view.handleSaveModFailure(err) + } + } else { + view.modalStateMachine.SetState(&saveModAsStartState{ + machine: view.modalStateMachine, + view: view, + }) + } +} + +func (view *View) startAddingManifestEntry() { + view.modalStateMachine.SetState(&addManifestEntryStartState{ + machine: view.modalStateMachine, + view: view, + }) +} + +func (view *View) requestMoveManifestEntryUp() { + manifest := view.service.Mod().World() + entries := manifest.EntryCount() + if (view.model.selectedManifestEntry >= 0) && (view.model.selectedManifestEntry < (entries - 1)) { + view.requestMoveManifestEntry(view.model.selectedManifestEntry+1, view.model.selectedManifestEntry) + } +} + +func (view *View) requestMoveManifestEntryDown() { + if view.model.selectedManifestEntry > 0 { + view.requestMoveManifestEntry(view.model.selectedManifestEntry-1, view.model.selectedManifestEntry) + } +} + +func (view *View) requestMoveManifestEntry(to, from int) { + _ = view.commander.Register( + cmd.Named("moveManifestEntry"), + cmd.Reverse(view.taskToRestoreFocus()), + cmd.Reverse(view.taskToSelectEntry(from)), + cmd.Nested(func() error { return view.service.MoveManifestEntry(to, from) }), + cmd.Forward(view.taskToSelectEntry(to)), + cmd.Forward(view.taskToRestoreFocus()), + ) +} + +func (view *View) tryAddManifestEntryFrom(names []string) error { + entry, err := world.NewManifestEntryFrom(names) + if err != nil { + return err + } + + view.requestAddManifestEntry(entry) + return nil +} + +func (view *View) requestAddManifestEntry(entry *world.ManifestEntry) { + at := view.model.selectedManifestEntry + 1 + _ = view.commander.Register( + cmd.Named("addManifestEntry"), + cmd.Reverse(view.taskToRestoreFocus()), + cmd.Reverse(view.taskToSelectEntry(view.model.selectedManifestEntry)), + cmd.Nested(func() error { return view.service.AddManifestEntry(at, entry) }), + cmd.Forward(view.taskToSelectEntry(at)), + cmd.Forward(view.taskToRestoreFocus()), + ) +} + +func (view *View) requestRemoveManifestEntry() { + manifest := view.service.Mod().World() + at := view.model.selectedManifestEntry + if (at < 0) || (at >= manifest.EntryCount()) { + return + } + + _ = view.commander.Register( + cmd.Named("removeManifestEntry"), + cmd.Reverse(view.taskToRestoreFocus()), + cmd.Reverse(view.taskToSelectEntry(view.model.selectedManifestEntry)), + cmd.Nested(func() error { return view.service.RemoveManifestEntry(at) }), + cmd.Forward(view.taskToSelectEntry(view.model.selectedManifestEntry-1)), + cmd.Forward(view.taskToRestoreFocus()), + ) +} + +func (view *View) tryLoadModFrom(names []string) error { + return view.service.TryLoadModFrom(names) +} + +func (view *View) requestSaveMod(modPath string) { + err := view.service.SaveModUnder(modPath) + if err != nil { + view.handleSaveModFailure(err) + } +} + +func (view *View) handleSaveModFailure(err error) { + view.modalStateMachine.SetState(&saveModFailedState{ + machine: view.modalStateMachine, + view: view, + errorInfo: err.Error(), + }) +} + +func (view *View) taskToRestoreFocus() cmd.Task { + return func(modder world.Modder) error { + view.model.restoreFocus = true + return nil + } +} + +func (view *View) taskToSelectEntry(index int) cmd.Task { + return func(modder world.Modder) error { + view.model.selectedManifestEntry = index + return nil + } +} diff --git a/modding/hacked/editor/project/ViewModel.go b/modding/hacked/editor/project/ViewModel.go new file mode 100644 index 0000000..3594ee5 --- /dev/null +++ b/modding/hacked/editor/project/ViewModel.go @@ -0,0 +1,14 @@ +package project + +type viewModel struct { + restoreFocus bool + windowOpen bool + selectedManifestEntry int +} + +func freshViewModel() viewModel { + return viewModel{ + windowOpen: false, + selectedManifestEntry: -1, + } +} diff --git a/modding/hacked/editor/render/Context.go b/modding/hacked/editor/render/Context.go new file mode 100644 index 0000000..9002f95 --- /dev/null +++ b/modding/hacked/editor/render/Context.go @@ -0,0 +1,15 @@ +package render + +import ( + "github.com/inkyblackness/hacked/ui/opengl" + + mgl "github.com/go-gl/mathgl/mgl32" +) + +// Context provides current render properties. +type Context struct { + OpenGL opengl.OpenGL + + ViewMatrix *mgl.Mat4 + ProjectionMatrix mgl.Mat4 +} diff --git a/modding/hacked/editor/render/FrameImage.go b/modding/hacked/editor/render/FrameImage.go new file mode 100644 index 0000000..07287d7 --- /dev/null +++ b/modding/hacked/editor/render/FrameImage.go @@ -0,0 +1,39 @@ +package render + +import ( + "math" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" +) + +// FrameImage renders an image centered and fitted within the given size. +func FrameImage(label string, frameCache *graphics.FrameCache, key graphics.FrameCacheKey, size imgui.Vec2) { + textureID := TextureIDForBitmapFrame(key) + + imgui.PushStyleColor(imgui.StyleColorChildBg, imgui.Vec4{X: 0, Y: 0, Z: 0, W: 1}) + imgui.PushStyleVarVec2(imgui.StyleVarWindowPadding, imgui.Vec2{X: 0, Y: 0}) + if imgui.BeginChildV(label, size, false, + imgui.WindowFlagsNoNav|imgui.WindowFlagsNoInputs|imgui.WindowFlagsNoScrollWithMouse| + imgui.WindowFlagsNoScrollbar) { + texture := frameCache.Texture(key) + if texture != nil { + var uv imgui.Vec2 + uv.X, uv.Y = texture.UV() + width, height := texture.Size() + + scaleFactor := float32(math.Min(float64(size.X/width), float64(size.Y/height))) + imageSize := imgui.Vec2{X: width * scaleFactor, Y: height * scaleFactor} + + bufferSize := imgui.Vec2{X: (size.X - imageSize.X) / 2, Y: (size.Y - imageSize.Y) / 2} + imgui.SetCursorPos(bufferSize) + + imgui.ImageV(textureID, imageSize, imgui.Vec2{}, uv, + imgui.Vec4{X: 1, Y: 1, Z: 1, W: 1}, imgui.Vec4{X: 0, Y: 0, Z: 0, W: 0}) + } + } + imgui.EndChild() + imgui.PopStyleVar() + imgui.PopStyleColor() +} diff --git a/modding/hacked/editor/render/OrientationView.go b/modding/hacked/editor/render/OrientationView.go new file mode 100644 index 0000000..1c8d69c --- /dev/null +++ b/modding/hacked/editor/render/OrientationView.go @@ -0,0 +1,234 @@ +package render + +import ( + "math" + + mgl "github.com/go-gl/mathgl/mgl32" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var orientationViewVertexShaderSource = ` +#version 150 +precision mediump float; + +in vec3 vertexPosition; + +uniform mat4 modelMatrix; +uniform mat4 viewMatrix; +uniform mat4 projectionMatrix; + +out vec3 position; +out float zCenter; + +void main(void) { + gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition, 1.0); + zCenter = (projectionMatrix * viewMatrix * modelMatrix * vec4(0.0, 0.0, 0.0, 1.0)).z; + position = gl_Position.xyz; +} +` + +var orientationViewFragmentShaderSource = ` +#version 150 +precision mediump float; + +uniform vec4 foregroundColor; +uniform vec4 backgroundColor; + +in vec3 position; +in float zCenter; + +out vec4 fragColor; + +void main(void) { + if (position.z <= zCenter) + { + fragColor = foregroundColor; + } + else + { + fragColor = backgroundColor; + } +} +` + +// OrientationView is a control that displays how an object is oriented. +// This is still not working properly. The orientation arrow is not properly rotated, +// and I suspect the typical issue of three-angle rotation versus quaternion rotation. +// Though I doubt the original engine used quaternions, it remains too confusing for me. +type OrientationView struct { + context Context + + program uint32 + vao *opengl.VertexArrayObject + vertexPositionBuffer uint32 + vertexPositionAttrib int32 + modelMatrixUniform opengl.Matrix4Uniform + viewMatrixUniform opengl.Matrix4Uniform + projectionMatrixUniform opengl.Matrix4Uniform + foregroundColorUniform opengl.Vector4Uniform + backgroundColorUniform opengl.Vector4Uniform + + baseOrientation mgl.Mat4 + rotation mgl.Vec3 + xRingVerticesStart int + yRingVerticesStart int + zRingVerticesStart int + vertices int +} + +// NewOrientationView returns a new instance. +func NewOrientationView(context Context, baseOrientation mgl.Mat4, rotation mgl.Vec3) *OrientationView { + gl := context.OpenGL + program, programErr := opengl.LinkNewStandardProgram(gl, orientationViewVertexShaderSource, orientationViewFragmentShaderSource) + + if programErr != nil { + panic(opengl.NamedShaderError{Name: "OrientationViewShader", Nested: programErr}) + } + + view := &OrientationView{ + context: context, + program: program, + + vao: opengl.NewVertexArrayObject(gl, program), + vertexPositionBuffer: gl.GenBuffers(1)[0], + vertexPositionAttrib: gl.GetAttribLocation(program, "vertexPosition"), + modelMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "modelMatrix")), + viewMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "viewMatrix")), + projectionMatrixUniform: opengl.Matrix4Uniform(gl.GetUniformLocation(program, "projectionMatrix")), + foregroundColorUniform: opengl.Vector4Uniform(gl.GetUniformLocation(program, "foregroundColor")), + backgroundColorUniform: opengl.Vector4Uniform(gl.GetUniformLocation(program, "backgroundColor")), + + baseOrientation: baseOrientation, + rotation: rotation, + } + { + gl.BindBuffer(opengl.ARRAY_BUFFER, view.vertexPositionBuffer) + var vertices []float32 + radius := 0.5 + vertices = append(vertices, 0.0, 0.0, 0.0) + vertices = append(vertices, 0.4, 0.0, 0.0) + vertices = append(vertices, 0.0, 0.0, 0.0) + vertices = append(vertices, 0.0, 0.4, 0.0) + vertices = append(vertices, 0.0, 0.0, 0.0) + vertices = append(vertices, 0.0, 0.0, 0.4) + + view.xRingVerticesStart = len(vertices) + for angle := 0.0; angle < float64(toRad(360.0)); angle += float64(toRad(4.0)) { + vertices = append(vertices, 0.0, float32(radius*math.Cos(angle)), float32(radius*math.Sin(angle))) + } + vertices = append(vertices, 0.0, float32(radius*math.Cos(0)), float32(radius*math.Sin(0))) + + view.yRingVerticesStart = len(vertices) + for angle := 0.0; angle < float64(toRad(360.0)); angle += float64(toRad(4.0)) { + vertices = append(vertices, float32(radius*math.Cos(angle)), 0.0, float32(radius*math.Sin(angle))) + } + vertices = append(vertices, float32(radius*math.Cos(0)), 0.0, float32(radius*math.Sin(0))) + + view.zRingVerticesStart = len(vertices) + for angle := 0.0; angle < float64(toRad(360.0)); angle += float64(toRad(4.0)) { + vertices = append(vertices, float32(radius*math.Cos(angle)), float32(radius*math.Sin(angle)), 0.0) + } + vertices = append(vertices, float32(radius*math.Cos(0)), float32(radius*math.Sin(0)), 0.0) + + view.vertices = len(vertices) + gl.BufferData(opengl.ARRAY_BUFFER, len(vertices)*4, vertices, opengl.STATIC_DRAW) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + } + view.vao.WithSetter(func(gl opengl.OpenGL) { + gl.EnableVertexAttribArray(uint32(view.vertexPositionAttrib)) + gl.BindBuffer(opengl.ARRAY_BUFFER, view.vertexPositionBuffer) + gl.VertexAttribOffset(uint32(view.vertexPositionAttrib), 3, opengl.FLOAT, false, 0, 0) + gl.BindBuffer(opengl.ARRAY_BUFFER, 0) + }) + + return view +} + +// Dispose cleans up resources of the view. +func (view *OrientationView) Dispose() { + gl := view.context.OpenGL + view.vao.Dispose() + gl.DeleteBuffers([]uint32{view.vertexPositionBuffer}) + gl.DeleteProgram(view.program) +} + +func toRad(degree float32) float32 { + return (degree * math.Pi * 2.0) / 360.0 +} + +// Render renders the orientation view for given orientation. +func (view *OrientationView) Render(orientation mgl.Vec3) { + gl := view.context.OpenGL + + view.vao.OnShader(func() { + view.projectionMatrixUniform.Set(gl, &view.context.ProjectionMatrix) + view.viewMatrixUniform.Set(gl, view.context.ViewMatrix) + + view.renderRings(false) + view.renderArrow( + mgl.Ident4(). + Mul4(mgl.HomogRotate3D(toRad(orientation[0]), mgl.Vec3{view.rotation[0], 0.0, 0.0})). + Mul4(mgl.HomogRotate3D(toRad(orientation[1]), mgl.Vec3{0.0, view.rotation[1], 0.0})). + Mul4(mgl.HomogRotate3D(toRad(orientation[2]), mgl.Vec3{0.0, 0.0, view.rotation[2]}))) + view.renderRings(true) + }) +} + +func (view *OrientationView) renderRings(front bool) { + foreground := func(color [4]float32) [4]float32 { + if !front { + return [4]float32{0.0, 0.0, 0.0, 0.0} + } + return color + } + background := func(color [4]float32) [4]float32 { + if front { + return [4]float32{0.0, 0.0, 0.0, 0.0} + } + return color + } + + // draw Z-rotation ring + view.renderRing(view.zRingVerticesStart, view.vertices, + foreground([4]float32{0.0, 0.0, 1.0, 1.0}), background([4]float32{0.0, 0.0, 0.8, 0.7})) + + // draw Y-rotation ring + view.renderRing(view.yRingVerticesStart, view.zRingVerticesStart, + foreground([4]float32{0.0, 1.0, 0.0, 1.0}), background([4]float32{0.0, 0.8, 0.0, 0.7})) + + // draw X-rotation ring + view.renderRing(view.xRingVerticesStart, view.yRingVerticesStart, + foreground([4]float32{1.0, 0.0, 0.0, 1.0}), background([4]float32{0.8, 0.0, 0.0, 0.7})) +} + +func (view *OrientationView) renderRing(start, end int, foregroundColor, backgroundColor [4]float32) { + gl := view.context.OpenGL + + modelMatrix := mgl.Ident4().Mul4(view.baseOrientation) + view.foregroundColorUniform.Set(gl, &foregroundColor) + view.backgroundColorUniform.Set(gl, &backgroundColor) + view.modelMatrixUniform.Set(gl, &modelMatrix) + gl.DrawArrays(opengl.LINES, int32(start)/3, int32(end-start)/3) +} + +func (view *OrientationView) renderArrow(rotation mgl.Mat4) { + gl := view.context.OpenGL + + modelMatrix := mgl.Ident4().Mul4(view.baseOrientation).Mul4(rotation) + view.foregroundColorUniform.Set(gl, &[4]float32{0.0, 0.0, 1.0, 1.0}) + view.backgroundColorUniform.Set(gl, &[4]float32{0.0, 0.0, 0.6, 1.0}) + view.modelMatrixUniform.Set(gl, &modelMatrix) + gl.DrawArrays(opengl.LINES, 4, 2) + + modelMatrix = mgl.Ident4().Mul4(view.baseOrientation).Mul4(rotation) + view.foregroundColorUniform.Set(gl, &[4]float32{0.0, 1.0, 0.0, 1.0}) + view.backgroundColorUniform.Set(gl, &[4]float32{0.0, 0.6, 0.0, 1.0}) + view.modelMatrixUniform.Set(gl, &modelMatrix) + gl.DrawArrays(opengl.LINES, 2, 2) + + modelMatrix = mgl.Ident4().Mul4(view.baseOrientation).Mul4(rotation) + view.foregroundColorUniform.Set(gl, &[4]float32{1.0, 0.0, 0.0, 1.0}) + view.backgroundColorUniform.Set(gl, &[4]float32{0.6, 0.0, 0.0, 1.0}) + view.modelMatrixUniform.Set(gl, &modelMatrix) + gl.DrawArrays(opengl.LINES, 0, 2) +} diff --git a/modding/hacked/editor/render/TextureID.go b/modding/hacked/editor/render/TextureID.go new file mode 100644 index 0000000..b8a5ab6 --- /dev/null +++ b/modding/hacked/editor/render/TextureID.go @@ -0,0 +1,34 @@ +package render + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ui/gui" +) + +const ( + // BitmapTextureTypeResource specifies a bitmap texture directly from resources. + BitmapTextureTypeResource = 0x00 + // BitmapTextureTypeFrame specifies a bitmap texture from the frame cache. + BitmapTextureTypeFrame = 0x01 +) + +// TextureIDForBitmapTexture returns a texture ID that identifies a bitmap texture from resources. +func TextureIDForBitmapTexture(key resource.Key) imgui.TextureID { + id := imgui.TextureID(gui.ImageTypeBitmapTexture) << 56 + id |= imgui.TextureID(BitmapTextureTypeResource) << 48 + id |= imgui.TextureID(key.Lang) << 32 + id |= imgui.TextureID(key.ID) << 16 + id |= imgui.TextureID(key.Index & 0xFFFF) + return id +} + +// TextureIDForBitmapFrame returns a texture ID that identifies a bitmap texture from frame cache. +func TextureIDForBitmapFrame(key graphics.FrameCacheKey) imgui.TextureID { + id := imgui.TextureID(gui.ImageTypeBitmapTexture) << 56 + id |= imgui.TextureID(BitmapTextureTypeFrame) << 48 + id |= imgui.TextureID(key) + return id +} diff --git a/modding/hacked/editor/render/TextureImage.go b/modding/hacked/editor/render/TextureImage.go new file mode 100644 index 0000000..01f74d5 --- /dev/null +++ b/modding/hacked/editor/render/TextureImage.go @@ -0,0 +1,40 @@ +package render + +import ( + "math" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// TextureImage renders an image centered and fitted within the given size. +func TextureImage(label string, cache *graphics.TextureCache, key resource.Key, size imgui.Vec2) { + textureID := TextureIDForBitmapTexture(key) + + imgui.PushStyleColor(imgui.StyleColorChildBg, imgui.Vec4{X: 0, Y: 0, Z: 0, W: 1}) + imgui.PushStyleVarVec2(imgui.StyleVarWindowPadding, imgui.Vec2{X: 0, Y: 0}) + if imgui.BeginChildV(label, size, false, + imgui.WindowFlagsNoNav|imgui.WindowFlagsNoInputs|imgui.WindowFlagsNoScrollWithMouse| + imgui.WindowFlagsNoScrollbar) { + texture, err := cache.Texture(key) + if err == nil { + var uv imgui.Vec2 + uv.X, uv.Y = texture.UV() + width, height := texture.Size() + + scaleFactor := float32(math.Min(float64(size.X/width), float64(size.Y/height))) + imageSize := imgui.Vec2{X: width * scaleFactor, Y: height * scaleFactor} + + bufferSize := imgui.Vec2{X: (size.X - imageSize.X) / 2, Y: (size.Y - imageSize.Y) / 2} + imgui.SetCursorPos(bufferSize) + + imgui.ImageV(textureID, imageSize, imgui.Vec2{}, uv, + imgui.Vec4{X: 1, Y: 1, Z: 1, W: 1}, imgui.Vec4{X: 0, Y: 0, Z: 0, W: 0}) + } + } + imgui.EndChild() + imgui.PopStyleVar() + imgui.PopStyleColor() +} diff --git a/modding/hacked/editor/render/TextureRenderer.go b/modding/hacked/editor/render/TextureRenderer.go new file mode 100644 index 0000000..8ed7f02 --- /dev/null +++ b/modding/hacked/editor/render/TextureRenderer.go @@ -0,0 +1,88 @@ +package render + +import ( + "unsafe" + + "github.com/inkyblackness/hacked/ui/opengl" +) + +// TextureRenderer is a renderer within which something can be rendered onto a texture. +// This texture can then be used to be displayed. +type TextureRenderer struct { + gl opengl.OpenGL + + framebuffer uint32 + texture uint32 + + width int32 + height int32 +} + +// NewTextureRenderer returns a new instance. +func NewTextureRenderer(gl opengl.OpenGL) *TextureRenderer { + renderer := &TextureRenderer{ + gl: gl, + + framebuffer: gl.GenFramebuffers(1)[0], + texture: gl.GenTextures(1)[0], + + width: 200, + height: 200, + } + + gl.BindTexture(opengl.TEXTURE_2D, renderer.texture) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MAG_FILTER, opengl.NEAREST) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MIN_FILTER, opengl.NEAREST) + offset := unsafe.Pointer(uintptr(0)) // nolint: govet + gl.TexImage2D(opengl.TEXTURE_2D, 0, opengl.RGBA, renderer.width, renderer.height, 0, opengl.RGBA, opengl.UNSIGNED_BYTE, offset) + // gl.GenerateMipmap(opengl.TEXTURE_2D) + gl.BindTexture(opengl.TEXTURE_2D, 0) + + renderer.onFramebuffer(func() { + gl.BindTexture(opengl.TEXTURE_2D, renderer.texture) + gl.FramebufferTexture(opengl.FRAMEBUFFER, opengl.COLOR_ATTACHMENT0, renderer.texture, 0) + gl.DrawBuffers([]uint32{opengl.COLOR_ATTACHMENT0}) + gl.BindTexture(opengl.TEXTURE_2D, 0) + + // result := gl.CheckFramebufferStatus(opengl.FRAMEBUFFER) + // fmt.Printf("status: 0x%04X\n", result) + // result == FRAMEBUFFER_COMPLETE == 0x8CD5 + }) + return renderer +} + +// Dispose cleans up resources. +func (renderer *TextureRenderer) Dispose() { + gl := renderer.gl + gl.DeleteFramebuffers([]uint32{renderer.framebuffer}) + gl.DeleteTextures([]uint32{renderer.texture}) +} + +// Handle returns the texture handle. +func (renderer *TextureRenderer) Handle() uint32 { + return renderer.texture +} + +// Size returns the dimensions of the texture, in pixels. +func (renderer *TextureRenderer) Size() (width, height float32) { + return float32(renderer.width), float32(renderer.height) +} + +// Render calls the nested function. All drawing commands within this function will end up in the texture. +func (renderer *TextureRenderer) Render(nested func()) { + gl := renderer.gl + renderer.onFramebuffer(func() { + gl.Viewport(0, 0, renderer.width, renderer.height) + gl.ClearColor(0.0, 0.0, 0.0, 0.0) + gl.Clear(opengl.COLOR_BUFFER_BIT | opengl.DEPTH_BUFFER_BIT) + + nested() + }) +} + +func (renderer *TextureRenderer) onFramebuffer(nested func()) { + gl := renderer.gl + gl.BindFramebuffer(opengl.FRAMEBUFFER, renderer.framebuffer) + nested() + gl.BindFramebuffer(opengl.FRAMEBUFFER, 0) +} diff --git a/modding/hacked/editor/render/TextureSelector.go b/modding/hacked/editor/render/TextureSelector.go new file mode 100644 index 0000000..d6e671f --- /dev/null +++ b/modding/hacked/editor/render/TextureSelector.go @@ -0,0 +1,44 @@ +package render + +import ( + "fmt" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// TextureSelector renders a horizontal list of game textures, with selection capability. +func TextureSelector(label string, width float32, guiScale float32, + count int, selectedIndex int, cache *graphics.TextureCache, keyResolver func(int) resource.Key, + tooltipText func(int) string, + changeCallback func(int)) { + if imgui.BeginChildV(label, imgui.Vec2{X: width * guiScale, Y: 100 * guiScale}, true, + imgui.WindowFlagsHorizontalScrollbar|imgui.WindowFlagsNoScrollWithMouse) { + for i := 0; i < count; i++ { + key := keyResolver(i) + imgui.PushStyleVarVec2(imgui.StyleVarWindowPadding, imgui.Vec2{X: 0, Y: 0}) + if imgui.BeginChildV(fmt.Sprintf("%3d", i), imgui.Vec2{X: 80 * guiScale, Y: 64 * guiScale}, false, + imgui.WindowFlagsNoNav|imgui.WindowFlagsNoScrollWithMouse) { + imgui.BeginGroup() + if imgui.SelectableV("", selectedIndex == i, 0, imgui.Vec2{X: 0, Y: 64 * guiScale}) { + changeCallback(i) + } + imgui.SameLine() + TextureImage(fmt.Sprintf("%3d", i), cache, key, imgui.Vec2{X: 64 * guiScale, Y: 64 * guiScale}) + imgui.EndGroup() + if imgui.IsItemHovered() { + text := tooltipText(i) + if len(text) > 0 { + imgui.SetTooltip(text) + } + } + } + imgui.EndChild() + imgui.PopStyleVar() + imgui.SameLine() + } + } + imgui.EndChild() +} diff --git a/modding/hacked/editor/sounds/View.go b/modding/hacked/editor/sounds/View.go new file mode 100644 index 0000000..af9d98c --- /dev/null +++ b/modding/hacked/editor/sounds/View.go @@ -0,0 +1,143 @@ +package sounds + +import ( + "fmt" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/external" + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/edit/undoable" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/gui" +) + +// View provides edit controls for sound effects. +type View struct { + soundEffectService undoable.SoundEffectService + + modalStateMachine gui.ModalStateMachine + guiScale float32 + + model viewModel +} + +// NewSoundEffectsView returns a new instance. +func NewSoundEffectsView(soundEffectService undoable.SoundEffectService, + modalStateMachine gui.ModalStateMachine, + guiScale float32) *View { + view := &View{ + soundEffectService: soundEffectService, + + modalStateMachine: modalStateMachine, + guiScale: guiScale, + + model: freshViewModel(), + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *View) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render renders the view. +func (view *View) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + imgui.SetNextWindowSizeV(imgui.Vec2{X: 400 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV("Sound Effects", view.WindowOpen(), imgui.WindowFlagsNoCollapse) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + info, _ := ids.Info(view.model.currentKey.ID) + + imgui.BeginChildV("SoundEffects", imgui.Vec2{X: -1, Y: -60 * view.guiScale}, true, 0) + for i := 0; i < info.MaxCount; i++ { + effects := ids.SoundEffectsForAudio(i) + text := fmt.Sprintf("%3d", i) + if len(effects) > 0 { + text += " - " + for effectIndex, effect := range effects { + if effectIndex > 0 { + text += ", " + } + text += effect.Name + } + } else { + text += " (unidentified)" + } + if imgui.SelectableV(text, view.model.currentKey.Index == i, 0, imgui.Vec2{}) { + view.model.currentKey.Index = i + } + } + imgui.EndChild() + + imgui.PushItemWidth(-100 * view.guiScale) + sound := view.currentSound() + if !sound.Empty() { + imgui.LabelText("Audio", fmt.Sprintf("%.2f sec", sound.Duration())) + if imgui.Button("Export") { + view.requestExportAudio(sound) + } + imgui.SameLine() + } else { + imgui.LabelText("Audio", "(no sound)") + } + if imgui.Button("Import") { + view.requestImportAudio() + } + imgui.PopItemWidth() + imgui.SameLine() + if imgui.Button("Clear") { + view.clearAudio() + } + if view.soundEffectService.Modified(view.model.currentKey) { + imgui.SameLine() + if imgui.Button("Remove") { + view.removeAudio() + } + } +} + +func (view *View) clearAudio() { + view.soundEffectService.RequestClear(view.model.currentKey, view.restoreFunc()) +} + +func (view *View) removeAudio() { + view.soundEffectService.RequestRemove(view.model.currentKey, view.restoreFunc()) +} + +func (view *View) currentSound() (sound audio.L8) { + return view.soundEffectService.Audio(view.model.currentKey) +} + +func (view *View) requestExportAudio(sound audio.L8) { + filename := fmt.Sprintf("sfx_%03d.wav", view.model.currentKey.Index) + + external.ExportAudio(view.modalStateMachine, filename, sound) +} + +func (view *View) requestImportAudio() { + external.ImportAudio(view.modalStateMachine, func(sound audio.L8) { + view.soundEffectService.RequestSetAudio(view.model.currentKey, sound, view.restoreFunc()) + }) +} + +func (view *View) restoreFunc() func() { + oldKey := view.model.currentKey + + return func() { + view.model.restoreFocus = true + view.model.currentKey = oldKey + } +} diff --git a/modding/hacked/editor/sounds/ViewModel.go b/modding/hacked/editor/sounds/ViewModel.go new file mode 100644 index 0000000..7266882 --- /dev/null +++ b/modding/hacked/editor/sounds/ViewModel.go @@ -0,0 +1,18 @@ +package sounds + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +type viewModel struct { + windowOpen bool + restoreFocus bool + currentKey resource.Key +} + +func freshViewModel() viewModel { + return viewModel{ + currentKey: resource.KeyOf(ids.SoundEffectsAudioStart, resource.LangDefault, 0), + } +} diff --git a/modding/hacked/editor/texts/View.go b/modding/hacked/editor/texts/View.go new file mode 100644 index 0000000..d920bb8 --- /dev/null +++ b/modding/hacked/editor/texts/View.go @@ -0,0 +1,198 @@ +package texts + +import ( + "fmt" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/external" + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/undoable" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/gui" +) + +// View provides edit controls for texts. +type View struct { + textService undoable.AugmentedTextService + + modalStateMachine gui.ModalStateMachine + clipboard external.Clipboard + guiScale float32 + + model viewModel +} + +// NewTextsView returns a new instance. +func NewTextsView(textService undoable.AugmentedTextService, + modalStateMachine gui.ModalStateMachine, clipboard external.Clipboard, + guiScale float32) *View { + view := &View{ + textService: textService, + + modalStateMachine: modalStateMachine, + clipboard: clipboard, + guiScale: guiScale, + + model: freshViewModel(), + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *View) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render renders the view. +func (view *View) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + imgui.SetNextWindowSizeV(imgui.Vec2{X: 400 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV("Texts", view.WindowOpen(), imgui.WindowFlagsNoCollapse) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + knownTexts := edit.KnownTexts() + imgui.PushItemWidth(-100 * view.guiScale) + if imgui.BeginCombo("Text Type", knownTexts.Title(view.model.currentKey.ID)) { + for _, info := range knownTexts { + if imgui.SelectableV(info.Title, info.ID == view.model.currentKey.ID, 0, imgui.Vec2{}) { + view.model.currentKey.ID = info.ID + view.model.currentKey.Index = 0 + } + } + imgui.EndCombo() + } + info, _ := ids.Info(view.model.currentKey.ID) + gui.StepSliderInt("Index", &view.model.currentKey.Index, 0, info.MaxCount-1) + + if imgui.BeginCombo("Language", view.model.currentKey.Lang.String()) { + languages := resource.Languages() + for _, lang := range languages { + if imgui.SelectableV(lang.String(), lang == view.model.currentKey.Lang, 0, imgui.Vec2{}) { + view.model.currentKey.Lang = lang + } + } + imgui.EndCombo() + } + + if view.textIsWithAudio() { + imgui.Separator() + sound := view.currentSound() + if !sound.Empty() { + imgui.LabelText("Audio", fmt.Sprintf("%.2f sec", sound.Duration())) + if imgui.Button("Export") { + view.requestExportAudio(sound) + } + imgui.SameLine() + } else { + imgui.LabelText("Audio", "(no sound)") + } + if imgui.Button("Import") { + view.requestImportAudio() + } + } + imgui.Separator() + + imgui.PopItemWidth() + + currentText := view.currentText() + imgui.BeginChildV("Text", imgui.Vec2{X: -100 * view.guiScale, Y: 0}, true, 0) + imgui.PushTextWrapPos() + if len(currentText) == 0 { + imgui.PushStyleColor(imgui.StyleColorText, imgui.Vec4{X: 1.0, Y: 1.0, Z: 1.0, W: 0.5}) + imgui.Text("(empty)") + imgui.PopStyleColor() + } else { + imgui.Text(currentText) + } + imgui.PopTextWrapPos() + imgui.EndChild() + + imgui.SameLine() + + imgui.BeginGroup() + if imgui.ButtonV("-> Clip", imgui.Vec2{X: -1, Y: 0}) { + view.copyTextToClipboard(currentText) + } + if imgui.ButtonV("<- Clip", imgui.Vec2{X: -1, Y: 0}) { + view.setTextFromClipboard() + } + if imgui.ButtonV("Clear", imgui.Vec2{X: -1, Y: 0}) { + view.clearText() + } + if imgui.ButtonV("Remove", imgui.Vec2{X: -1, Y: 0}) { + view.removeText() + } + imgui.EndGroup() +} + +func (view View) currentText() string { + return view.textService.Text(view.model.currentKey) +} + +func (view View) copyTextToClipboard(text string) { + if len(text) > 0 { + view.clipboard.SetString(text) + } +} + +func (view *View) setTextFromClipboard() { + value, err := view.clipboard.String() + if err != nil { + return + } + + view.textService.RequestSetText(view.model.currentKey, value, view.restoreFunc()) +} + +func (view *View) clearText() { + view.textService.RequestClear(view.model.currentKey, view.restoreFunc()) +} + +func (view *View) removeText() { + view.textService.RequestRemove(view.model.currentKey, view.restoreFunc()) +} + +func (view View) textIsWithAudio() bool { + return view.textService.WithAudio(view.model.currentKey) +} + +func (view *View) currentSound() (sound audio.L8) { + return view.textService.Sound(view.model.currentKey) +} + +func (view *View) requestExportAudio(sound audio.L8) { + if !view.textIsWithAudio() { + return + } + filename := fmt.Sprintf("%05d_%03d_%s.wav", view.model.currentKey.ID, view.model.currentKey.Index, view.model.currentKey.Lang.String()) + + external.ExportAudio(view.modalStateMachine, filename, sound) +} + +func (view *View) requestImportAudio() { + external.ImportAudio(view.modalStateMachine, func(sound audio.L8) { + view.textService.RequestSetSound(view.model.currentKey, sound, view.restoreFunc()) + }) +} + +func (view *View) restoreFunc() func() { + oldKey := view.model.currentKey + + return func() { + view.model.restoreFocus = true + view.model.currentKey = oldKey + } +} diff --git a/modding/hacked/editor/texts/ViewModel.go b/modding/hacked/editor/texts/ViewModel.go new file mode 100644 index 0000000..26f0bbb --- /dev/null +++ b/modding/hacked/editor/texts/ViewModel.go @@ -0,0 +1,18 @@ +package texts + +import ( + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/resource" +) + +type viewModel struct { + windowOpen bool + restoreFocus bool + currentKey resource.Key +} + +func freshViewModel() viewModel { + return viewModel{ + currentKey: resource.KeyOf(edit.KnownTexts()[0].ID, resource.LangDefault, 0), + } +} diff --git a/modding/hacked/editor/textures/SetTextureBitmapCommand.go b/modding/hacked/editor/textures/SetTextureBitmapCommand.go new file mode 100644 index 0000000..b8f8de9 --- /dev/null +++ b/modding/hacked/editor/textures/SetTextureBitmapCommand.go @@ -0,0 +1,50 @@ +package textures + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +type setTextureBitmapCommand struct { + model *viewModel + + textureIndex int + id resource.ID + + oldData []byte + newData []byte +} + +func (command setTextureBitmapCommand) Do(modder world.Modder) error { + return command.perform(modder, command.newData) +} + +func (command setTextureBitmapCommand) Undo(modder world.Modder) error { + return command.perform(modder, command.oldData) +} + +func (command setTextureBitmapCommand) perform(modder world.Modder, data []byte) error { + info, existing := ids.Info(command.id) + if !existing { + panic(fmt.Sprintf("unknown identifier for bitmap resource: %v", command.id)) + } + resourceID := command.id + blockIndex := command.textureIndex + if !info.List { + resourceID = resourceID.Plus(blockIndex) + blockIndex = 0 + } + + if (len(data) > 0) || info.List { + modder.SetResourceBlock(resource.LangAny, resourceID, blockIndex, data) + } else { + modder.DelResource(resource.LangAny, resourceID) + } + + command.model.restoreFocus = true + command.model.currentIndex = command.textureIndex + return nil +} diff --git a/modding/hacked/editor/textures/SetTexturePropertiesCommand.go b/modding/hacked/editor/textures/SetTexturePropertiesCommand.go new file mode 100644 index 0000000..8a9f81b --- /dev/null +++ b/modding/hacked/editor/textures/SetTexturePropertiesCommand.go @@ -0,0 +1,31 @@ +package textures + +import ( + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/world" +) + +type setTexturePropertiesCommand struct { + model *viewModel + + textureIndex int + + oldProperties texture.Properties + newProperties texture.Properties +} + +func (cmd setTexturePropertiesCommand) Do(modder world.Modder) error { + return cmd.perform(modder, cmd.newProperties) +} + +func (cmd setTexturePropertiesCommand) Undo(modder world.Modder) error { + return cmd.perform(modder, cmd.oldProperties) +} + +func (cmd setTexturePropertiesCommand) perform(modder world.Modder, properties texture.Properties) error { + modder.SetTextureProperties(cmd.textureIndex, properties) + + cmd.model.restoreFocus = true + cmd.model.currentIndex = cmd.textureIndex + return nil +} diff --git a/modding/hacked/editor/textures/SetTextureTextCommand.go b/modding/hacked/editor/textures/SetTextureTextCommand.go new file mode 100644 index 0000000..0bd9efe --- /dev/null +++ b/modding/hacked/editor/textures/SetTextureTextCommand.go @@ -0,0 +1,31 @@ +package textures + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +type setTextureTextCommand struct { + model *viewModel + + key resource.Key + + oldData []byte + newData []byte +} + +func (command setTextureTextCommand) Do(modder world.Modder) error { + return command.perform(modder, command.newData) +} + +func (command setTextureTextCommand) Undo(modder world.Modder) error { + return command.perform(modder, command.oldData) +} + +func (command setTextureTextCommand) perform(modder world.Modder, data []byte) error { + modder.SetResourceBlock(command.key.Lang, command.key.ID, command.key.Index, data) + command.model.restoreFocus = true + command.model.currentIndex = command.key.Index + command.model.currentLang = command.key.Lang + return nil +} diff --git a/modding/hacked/editor/textures/View.go b/modding/hacked/editor/textures/View.go new file mode 100644 index 0000000..14365e8 --- /dev/null +++ b/modding/hacked/editor/textures/View.go @@ -0,0 +1,402 @@ +package textures + +import ( + "fmt" + "math" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/editor/external" + "github.com/inkyblackness/hacked/editor/graphics" + "github.com/inkyblackness/hacked/editor/render" + "github.com/inkyblackness/hacked/editor/values" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" + "github.com/inkyblackness/hacked/ui/gui" +) + +// View provides edit controls for textures. +type View struct { + mod *world.Mod + textCache *text.Cache + cp text.Codepage + imageCache *graphics.TextureCache + paletteCache *graphics.PaletteCache + + modalStateMachine gui.ModalStateMachine + clipboard external.Clipboard + guiScale float32 + commander cmd.Commander + + model viewModel +} + +// NewTexturesView returns a new instance. +func NewTexturesView(mod *world.Mod, textCache *text.Cache, cp text.Codepage, + imageCache *graphics.TextureCache, paletteCache *graphics.PaletteCache, + modalStateMachine gui.ModalStateMachine, + clipboard external.Clipboard, guiScale float32, commander cmd.Commander) *View { + view := &View{ + mod: mod, + textCache: textCache, + cp: cp, + imageCache: imageCache, + paletteCache: paletteCache, + + modalStateMachine: modalStateMachine, + clipboard: clipboard, + guiScale: guiScale, + commander: commander, + + model: freshViewModel(), + } + return view +} + +// WindowOpen returns the flag address, to be used with the main menu. +func (view *View) WindowOpen() *bool { + return &view.model.windowOpen +} + +// Render renders the view. +func (view *View) Render() { + if view.model.restoreFocus { + imgui.SetNextWindowFocus() + view.model.restoreFocus = false + view.model.windowOpen = true + } + if view.model.windowOpen { + imgui.SetNextWindowSizeV(imgui.Vec2{X: 800 * view.guiScale, Y: 300 * view.guiScale}, imgui.ConditionFirstUseEver) + if imgui.BeginV("Textures", view.WindowOpen(), imgui.WindowFlagsNoCollapse|imgui.WindowFlagsHorizontalScrollbar) { + view.renderContent() + } + imgui.End() + } +} + +func (view *View) renderContent() { + if imgui.BeginChildV("Properties", imgui.Vec2{X: -300 * view.guiScale, Y: 0}, false, imgui.WindowFlagsHorizontalScrollbar) { + imgui.PushItemWidth(-200 * view.guiScale) + + gui.StepSliderInt("Index", &view.model.currentIndex, 0, world.MaxWorldTextures-1) + + render.TextureSelector("###IndexBitmap", -1, view.guiScale, world.MaxWorldTextures, + view.model.currentIndex, view.imageCache, + func(index int) resource.Key { + return view.indexedResourceKey(ids.LargeTextures, index) + }, + view.textureTooltip, + func(newValue int) { + view.model.currentIndex = newValue + }) + + readOnly := !view.mod.HasModifiableTextureProperties() + + imgui.Separator() + if imgui.BeginCombo("Language", view.model.currentLang.String()) { + languages := resource.Languages() + for _, lang := range languages { + if imgui.SelectableV(lang.String(), lang == view.model.currentLang, 0, imgui.Vec2{}) { + view.model.currentLang = lang + } + } + imgui.EndCombo() + } + + nameKey := resource.KeyOf(ids.TextureNames, view.model.currentLang, view.model.currentIndex) + name, _ := view.textCache.Text(nameKey) + view.renderText(readOnly, "Name", name, view.requestSetTextureName) + + useKey := resource.KeyOf(ids.TextureUsages, view.model.currentLang, view.model.currentIndex) + use, _ := view.textCache.Text(useKey) + view.renderText(readOnly, "Use", use, view.requestSetTextureUsage) + + imgui.Separator() + view.renderTextureProperties(readOnly) + + imgui.PopItemWidth() + } + imgui.EndChild() + imgui.SameLine() + + imgui.BeginGroup() + view.renderTextureSample("Large", ids.LargeTextures, 128, "Large") + view.renderTextureSample("Medium", ids.MediumTextures, 64, "Medium") + view.renderTextureSample("Small", ids.SmallTextures, 32, "Small") + view.renderTextureSample("Icon", ids.IconTextures, 16, "Icon") + imgui.EndGroup() +} + +func (view *View) renderText(readOnly bool, label string, value string, changeCallback func(string)) { + imgui.LabelText(label, value) + view.clipboardPopup(readOnly, label, value, changeCallback) +} + +func (view *View) clipboardPopup(readOnly bool, label string, value string, changeCallback func(string)) { + if imgui.BeginPopupContextItemV(label+"-Popup", 1) { + if imgui.Selectable("Copy to Clipboard") { + view.clipboard.SetString(value) + } + if !readOnly && imgui.Selectable("Copy from Clipboard") { + newValue, err := view.clipboard.String() + if err == nil { + changeCallback(newValue) + } + } + imgui.EndPopup() + } +} + +func (view *View) renderTextureProperties(readOnly bool) { + list := view.mod.TextureProperties() + distanceUnifier := values.NewUnifier() + climbableUnifier := values.NewUnifier() + transparencyControlUnifier := values.NewUnifier() + animationGroupUnifier := values.NewUnifier() + animationIndexUnifier := values.NewUnifier() + if view.model.currentIndex < len(list) { + properties := list[view.model.currentIndex] + distanceUnifier.Add(int(properties.DistanceModifier)) + climbableUnifier.Add(properties.Climbable != 0) + transparencyControlUnifier.Add(int(properties.TransparencyControl)) + animationGroupUnifier.Add(int(properties.AnimationGroup)) + animationIndexUnifier.Add(int(properties.AnimationIndex)) + } + + values.RenderUnifiedSliderInt(readOnly, "Distance Modifier", distanceUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + math.MinInt16, math.MaxInt16, + func(newValue int) { + view.requestChangeProperties(func(prop *texture.Properties) { + prop.DistanceModifier = int16(newValue) + }) + }) + + values.RenderUnifiedCheckboxCombo(readOnly, "Climbable", climbableUnifier, + func(newValue bool) { + view.requestChangeProperties(func(prop *texture.Properties) { + prop.Climbable = 0 + if newValue { + prop.Climbable = 1 + } + }) + }) + + values.RenderUnifiedCombo(readOnly, "Transparency Control", transparencyControlUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(index int) string { return texture.TransparencyControl(index).String() }, + len(texture.TransparencyControls()), + func(newValue int) { + view.requestChangeProperties(func(prop *texture.Properties) { + prop.TransparencyControl = texture.TransparencyControl(newValue) + }) + }) + + values.RenderUnifiedSliderInt(readOnly, "Animation Group", animationGroupUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, 3, + func(newValue int) { + view.requestChangeProperties(func(prop *texture.Properties) { + prop.AnimationGroup = byte(newValue) + }) + }) + values.RenderUnifiedSliderInt(readOnly, "Animation Index", animationIndexUnifier, + func(u values.Unifier) int { return u.Unified().(int) }, + func(value int) string { return "%d" }, + 0, 3, + func(newValue int) { + view.requestChangeProperties(func(prop *texture.Properties) { + prop.AnimationIndex = byte(newValue) + }) + }) +} + +func (view *View) renderTextureSample(label string, id resource.ID, sideLength float32, sizeID string) { + if imgui.BeginChildV(label, imgui.Vec2{X: -1, Y: (128 + 7) * view.guiScale}, true, imgui.WindowFlagsNoScrollbar) { + key := view.indexedResourceKey(id, view.model.currentIndex) + render.TextureImage("Texture Bitmap", view.imageCache, key, + imgui.Vec2{X: sideLength * view.guiScale, Y: sideLength * view.guiScale}) + + imgui.SameLine() + imgui.BeginGroup() + + tex, err := view.imageCache.Texture(key) + + if imgui.Button("Clear") { + view.requestClear(id, view.model.currentIndex, int(sideLength)) + } + imgui.SameLine() + if imgui.Button("Import") { + view.requestImport(id, view.model.currentIndex) + } + if err == nil { + if imgui.Button("Export") { + view.requestExport(id, view.model.currentIndex, sizeID) + } + if view.hasModCurrentBitmap() { + imgui.SameLine() + if imgui.Button("Remove") { + view.requestSetBitmapData(id, view.model.currentIndex, nil) + } + } + + width, height := tex.Size() + imgui.Text(fmt.Sprintf("%d x %d px", int(width), int(height))) + } + + imgui.EndGroup() + } + imgui.EndChild() +} + +func (view *View) currentResourceKey() resource.Key { + return view.indexedResourceKey(ids.LargeTextures, view.model.currentIndex) +} + +func (view *View) indexedResourceKey(id resource.ID, index int) resource.Key { + info, _ := ids.Info(id) + if info.List { + return resource.KeyOf(id, resource.LangAny, index) + } + return resource.KeyOf(id.Plus(index), resource.LangAny, 0) +} + +func (view *View) textureTooltip(index int) string { + key := resource.KeyOf(ids.TextureNames, resource.LangDefault, index) + name, err := view.textCache.Text(key) + suffix := "" + if err == nil { + suffix = ": " + name + } + return fmt.Sprintf("%3d", index) + suffix +} + +func (view *View) hasModCurrentBitmap() bool { + key := view.currentResourceKey() + return len(view.mod.ModifiedBlock(key.Lang, key.ID, key.Index)) > 0 +} + +func (view *View) requestChangeProperties(modifier func(*texture.Properties)) { + list := view.mod.TextureProperties() + if view.model.currentIndex < len(list) { + command := setTexturePropertiesCommand{ + model: &view.model, + textureIndex: view.model.currentIndex, + oldProperties: list[view.model.currentIndex], + newProperties: list[view.model.currentIndex], + } + modifier(&command.newProperties) + view.commander.Queue(command) + } +} + +func (view *View) requestSetTextureName(value string) { + view.requestSetTextureText(ids.TextureNames, value) +} + +func (view *View) requestSetTextureUsage(value string) { + view.requestSetTextureText(ids.TextureUsages, value) +} + +func (view *View) requestSetTextureText(id resource.ID, newValue string) { + key := resource.KeyOf(id, view.model.currentLang, view.model.currentIndex) + oldValue, _ := view.textCache.Text(key) + + if oldValue != newValue { + command := setTextureTextCommand{ + model: &view.model, + key: key, + oldData: view.cp.Encode(oldValue), + newData: view.cp.Encode(text.Blocked(newValue)[0]), + } + view.commander.Queue(command) + } +} + +func (view *View) requestExport(id resource.ID, index int, sizeID string) { + key := view.indexedResourceKey(id, index) + tex, err := view.imageCache.Texture(key) + if err != nil { + return + } + palette, err := view.paletteCache.Palette(0) + if err != nil { + return + } + rawPalette := palette.Palette() + filename := fmt.Sprintf("Texture_%03d_%s.png", index, sizeID) + width, height := tex.Size() + bmp := bitmap.Bitmap{ + Header: bitmap.Header{ + Width: int16(width), + Height: int16(height), + }, + Pixels: tex.PixelData(), + Palette: &rawPalette, + } + + external.ExportImage(view.modalStateMachine, filename, bmp) +} + +func (view *View) requestImport(id resource.ID, index int) { + paletteRetriever := func() (bitmap.Palette, error) { + palette, err := view.paletteCache.Palette(0) + if err != nil { + return bitmap.Palette{}, err + } + return palette.Palette(), nil + } + + external.ImportImage(view.modalStateMachine, paletteRetriever, func(bmp bitmap.Bitmap) { + view.requestSetBitmap(id, index, bmp) + }) +} + +func (view *View) requestClear(id resource.ID, index int, sideLength int) { + bmp := bitmap.Bitmap{ + Header: bitmap.Header{ + Width: int16(sideLength), + Height: int16(sideLength), + }, + Pixels: []byte{0x00}, + } + view.requestSetBitmap(id, index, bmp) +} + +func (view *View) requestSetBitmap(id resource.ID, index int, bmp bitmap.Bitmap) { + highestBitShift := func(value int16) (result byte) { + if value != 0 { + for (value >> result) != 1 { + result++ + } + } + return + } + + bmp.Header.Flags = 0 + bmp.Header.Type = bitmap.TypeFlat8Bit + bmp.Header.WidthFactor = highestBitShift(bmp.Header.Width) + bmp.Header.HeightFactor = highestBitShift(bmp.Header.Height) + bmp.Header.Stride = uint16(bmp.Header.Width) + data := bitmap.Encode(&bmp, 0) + view.requestSetBitmapData(id, index, data) +} + +func (view *View) requestSetBitmapData(id resource.ID, index int, newData []byte) { + resourceKey := view.indexedResourceKey(id, index) + command := setTextureBitmapCommand{ + model: &view.model, + id: id, + textureIndex: index, + oldData: view.mod.ModifiedBlock(resource.LangAny, resourceKey.ID, resourceKey.Index), + newData: newData, + } + view.commander.Queue(command) +} diff --git a/modding/hacked/editor/textures/ViewModel.go b/modding/hacked/editor/textures/ViewModel.go new file mode 100644 index 0000000..66c59f9 --- /dev/null +++ b/modding/hacked/editor/textures/ViewModel.go @@ -0,0 +1,20 @@ +package textures + +import ( + "github.com/inkyblackness/hacked/ss1/resource" +) + +type viewModel struct { + windowOpen bool + restoreFocus bool + + currentLang resource.Language + currentIndex int +} + +func freshViewModel() viewModel { + return viewModel{ + currentIndex: 0, + currentLang: resource.LangDefault, + } +} diff --git a/modding/hacked/editor/values/InterpreterControls.go b/modding/hacked/editor/values/InterpreterControls.go new file mode 100644 index 0000000..2ed32b9 --- /dev/null +++ b/modding/hacked/editor/values/InterpreterControls.go @@ -0,0 +1,167 @@ +package values + +import ( + "fmt" + "sort" + "strings" + + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +// StandardSimplifier returns a simplifier with common property controls. +func StandardSimplifier(readOnly bool, fullKey string, unifier Unifier, + updater func(func(uint32) uint32), objTypeRenderer ObjectTypeControlRenderer) *interpreters.Simplifier { + keys := strings.Split(fullKey, ".") + key := keys[len(keys)-1] + label := key + "###" + fullKey + + simplifier := interpreters.NewSimplifier(func(minValue, maxValue int64, formatter interpreters.RawValueFormatter) { + RenderUnifiedSliderInt(readOnly, label, unifier, + func(u Unifier) int { + unifiedValue := u.Unified().(int32) + if (minValue == -1) && (maxValue == 0x7FFF) { + unifiedValue = int32(int16(unifiedValue)) + } + return int(unifiedValue) + }, + func(value int) string { + result := formatter(value) + if len(result) == 0 { + result = "%d" + } else { + result += " - raw: %d" + } + return result + }, + int(minValue), int(maxValue), + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + }) + + simplifier.SetEnumValueHandler(func(enumValues map[uint32]string) { + valueKeys := make([]uint32, 0, len(enumValues)) + for valueKey := range enumValues { + valueKeys = append(valueKeys, valueKey) + } + sort.Slice(valueKeys, func(indexA, indexB int) bool { return valueKeys[indexA] < valueKeys[indexB] }) + + RenderUnifiedCombo(readOnly, label, unifier, + func(u Unifier) int { + unifiedValue := uint32(u.Unified().(int32)) + for index, valueKey := range valueKeys { + if valueKey == unifiedValue { + return index + } + } + return -1 + }, + func(index int) string { + if index < 0 { + return "" + } + return enumValues[valueKeys[index]] + }, + len(valueKeys), + func(newIndex int) { + updater(func(oldValue uint32) uint32 { return valueKeys[newIndex] }) + }) + }) + + simplifier.SetBitfieldHandler(func(maskNames map[uint32]string) { + masks := make([]uint32, 0, len(maskNames)) + for mask := range maskNames { + masks = append(masks, mask) + } + sort.Slice(masks, func(indexA, indexB int) bool { return masks[indexA] < masks[indexB] }) + + addMaskedItem := func(mask uint32) { + maxValue := mask + shift := 0 + maskedLabel := key + "." + maskNames[mask] + "###" + label + "-" + maskNames[mask] + + for (maxValue & 1) == 0 { + shift++ + maxValue >>= 1 + } + + if maxValue == 1 { + booleanUnifier := NewUnifier() + if unifier.IsUnique() { + booleanUnifier.Add((uint32(unifier.Unified().(int32)) & mask) != 0) + } + RenderUnifiedCheckboxCombo(readOnly, maskedLabel, booleanUnifier, + func(newValue bool) { + updater(func(oldValue uint32) uint32 { + result := oldValue & ^mask + if newValue { + result |= mask + } + return result + }) + }) + } else { + RenderUnifiedSliderInt(readOnly, maskedLabel, unifier, + func(u Unifier) int { return int((uint32(u.Unified().(int32)) & mask) >> uint32(shift)) }, + func(value int) string { return "%d" }, + 0, int(maxValue), + func(newValue int) { + updater(func(oldValue uint32) uint32 { + return (oldValue & ^mask) | (uint32(newValue) << uint32(shift)) + }) + }) + } + } + + for _, mask := range masks { + addMaskedItem(mask) + } + }) + + simplifier.SetRotationHandler(func(minValue, maxValue int64) { + RenderUnifiedRotation(readOnly, label, unifier, + int(minValue), int(maxValue), + RotationInfo{ + // This code is specialized for Hacker-Yaw. To re-use it for other rotations, details must change. + Horizontal: true, + Positive: true, + Clockwise: false, + }, + func(newValue int) { + updater(func(oldValue uint32) uint32 { return uint32(newValue) }) + }) + }) + + simplifier.SetSpecialHandler("ObjectTriple", func() { + var classNames [object.ClassCount]string + for index, class := range object.Classes() { + classNames[index] = class.String() + } + tripleResolver := func(u Unifier) object.Triple { return object.TripleFromInt(int(u.Unified().(int32))) } + RenderUnifiedCombo(readOnly, key+"-Class###"+fullKey+"-Class", unifier, + func(u Unifier) int { + triple := tripleResolver(u) + return int(triple.Class) + }, + func(value int) string { return fmt.Sprintf("%2d: %v", value, object.Class(value)) }, + object.ClassCount, + func(newValue int) { + triple := object.TripleFrom(newValue, 0, 0) + updater(func(oldValue uint32) uint32 { return uint32(triple.Int()) }) + }) + + objTypeRenderer.Render(readOnly, key+"###"+fullKey+"-Type", unifier, unifier, + tripleResolver, + func(newValue object.Triple) { + updater(func(oldValue uint32) uint32 { return uint32(newValue.Int()) }) + }) + }) + + simplifier.SetSpecialHandler("Mistake", func() {}) + simplifier.SetSpecialHandler("Ignored", func() {}) + simplifier.SetSpecialHandler("Unknown", func() {}) + simplifier.SetSpecialHandler("Internal", func() {}) + + return simplifier +} diff --git a/modding/hacked/editor/values/ObjectTypeControlRenderer.go b/modding/hacked/editor/values/ObjectTypeControlRenderer.go new file mode 100644 index 0000000..5dc9e3f --- /dev/null +++ b/modding/hacked/editor/values/ObjectTypeControlRenderer.go @@ -0,0 +1,71 @@ +package values + +import ( + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// ObjectTypeControlRenderer renders a control to select an object. +type ObjectTypeControlRenderer struct { + // Meta describes the layout of classes, subclasses, and types. + Meta object.PropertiesTable + // TextCache is used to retrieve the name of a type. + TextCache *text.Cache +} + +// Render creates the controls according to the given parameters. +func (renderer ObjectTypeControlRenderer) Render(readOnly bool, label string, + classUnifier Unifier, typeUnifier Unifier, + tripleResolver func(Unifier) object.Triple, + changeHandler func(object.Triple)) { + switch { + case classUnifier.IsUnique(): + class := tripleResolver(classUnifier).Class + triples := renderer.Meta.TriplesInClass(class) + selectedIndex := -1 + if typeUnifier.IsUnique() { + triple := tripleResolver(typeUnifier) + for index, availableTriple := range triples { + if availableTriple == triple { + selectedIndex = index + } + } + if selectedIndex < 0 { + selectedIndex = len(triples) + triples = append(triples, triple) + } + } + RenderUnifiedCombo(readOnly, label, typeUnifier, + func(u Unifier) int { return selectedIndex }, + func(value int) string { + triple := triples[value] + return renderer.tripleName(triple) + }, + len(triples), + func(newValue int) { + triple := triples[newValue] + changeHandler(triple) + }) + case !classUnifier.IsEmpty(): + imgui.LabelText(label, "(multiple classes)") + default: + imgui.LabelText(label, "") + } +} + +func (renderer ObjectTypeControlRenderer) tripleName(triple object.Triple) string { + suffix := "???" + linearIndex := renderer.Meta.TripleIndex(triple) + if (linearIndex >= 0) && (renderer.TextCache != nil) { + key := resource.KeyOf(ids.ObjectLongNames, resource.LangDefault, linearIndex) + objName, err := renderer.TextCache.Text(key) + if err == nil { + suffix = objName + } + } + return triple.String() + ": " + suffix +} diff --git a/modding/hacked/editor/values/Properties.go b/modding/hacked/editor/values/Properties.go new file mode 100644 index 0000000..2a159e1 --- /dev/null +++ b/modding/hacked/editor/values/Properties.go @@ -0,0 +1,156 @@ +package values + +import ( + "fmt" + "image/color" + "math" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/gui" +) + +const ( + hintMultiple = "(multiple)" + hintYes = "Yes" + hintNo = "No" +) + +// RenderUnifiedSliderInt renders a control for an unified integer value. +func RenderUnifiedSliderInt(readOnly bool, label string, unifier Unifier, + intConverter func(Unifier) int, formatter func(int) string, min, max int, changeHandler func(int)) { + var labelValue string + var selectedString string + selectedValue := -1 + if unifier.IsUnique() { + selectedValue = intConverter(unifier) + selectedString = formatter(selectedValue) + labelValue = fmt.Sprintf(selectedString, selectedValue) + } else if !unifier.IsEmpty() { + selectedString = hintMultiple + labelValue = selectedString + } + if readOnly { + imgui.LabelText(label, labelValue) + } else if gui.StepSliderIntV(label, &selectedValue, min, max, selectedString) { + changeHandler(selectedValue) + } +} + +// RenderUnifiedCombo renders a control for a unified enumeration value. +func RenderUnifiedCombo(readOnly bool, label string, unifier Unifier, + intConverter func(Unifier) int, formatter func(int) string, count int, changeHandler func(int)) { + var selectedString string + selectedIndex := -1 + if unifier.IsUnique() { + selectedIndex = intConverter(unifier) + selectedString = formatter(selectedIndex) + } else if !unifier.IsEmpty() { + selectedString = hintMultiple + } + if readOnly { + imgui.LabelText(label, selectedString) + } else if imgui.BeginCombo(label, selectedString) { + for i := 0; i < count; i++ { + entryString := formatter(i) + + if imgui.SelectableV(entryString, i == selectedIndex, 0, imgui.Vec2{}) { + changeHandler(i) + } + } + imgui.EndCombo() + } +} + +// RenderUnifiedCheckboxCombo renders a control for a unified boolean value. +func RenderUnifiedCheckboxCombo(readOnly bool, label string, unifier Unifier, changeHandler func(bool)) { + selectedString := "" + if unifier.IsUnique() { + selectedValue := unifier.Unified().(bool) + if selectedValue { + selectedString = hintYes + } else { + selectedString = hintNo + } + } else if !unifier.IsEmpty() { + selectedString = hintMultiple + } + if readOnly { + imgui.LabelText(label, selectedString) + } else if imgui.BeginCombo(label, selectedString) { + for i, text := range []string{hintNo, hintYes} { + if imgui.SelectableV(text, text == selectedString, 0, imgui.Vec2{}) { + changeHandler(i != 0) + } + } + imgui.EndCombo() + } +} + +// RotationInfo describes how the rotation is aligned in its zero position. +type RotationInfo struct { + Horizontal bool + Positive bool + Clockwise bool +} + +// RenderUnifiedRotation renders a control for rotation value. +func RenderUnifiedRotation(readOnly bool, label string, unifier Unifier, + min, max int, info RotationInfo, changeHandler func(int)) { + calcValuePercent := func(value int) float64 { + valueRange := (max - min) + 1 + return float64(value) / float64(valueRange) + } + RenderUnifiedSliderInt(readOnly, label, unifier, + func(u Unifier) int { + unifiedValue := u.Unified().(int32) + return int(unifiedValue) + }, + func(value int) string { + valuePercent := calcValuePercent(value) + result := fmt.Sprintf("%3.02f° - raw: %%d", valuePercent*360.0) + return result + }, + min, max, + changeHandler) + + if (imgui.IsItemFocused() || imgui.IsItemActive() || imgui.IsItemHovered()) && unifier.IsUnique() { + valuePercent := calcValuePercent(int(unifier.Unified().(int32))) + valueRadian := valuePercent * (math.Pi * 2) + + lineColor := imgui.Packed(color.RGBA{R: 0x21, G: 0xFF, B: 0x43, A: 0xFF}) // color copied from style. + lineThickness := float32(4.0) + imgui.BeginTooltip() + imgui.Dummy(imgui.Vec2{X: imgui.TextLineHeightWithSpacing() * 2, Y: imgui.TextLineHeightWithSpacing() * 2}) + dl := imgui.WindowDrawList() + winTopLeft := imgui.WindowPos() + winSize := imgui.WindowSize() + center := winTopLeft.Plus(winSize.Times(0.5)) + circleRadius := ((winSize.X / 2) * 5) / 6 + dl.AddCircleV(center, circleRadius, lineColor, 100, lineThickness) + + targetX := 0.0 + targetY := 0.0 + if info.Horizontal { + targetX = 1.0 + if !info.Positive { + targetX = -1.0 + } + } else { + targetY = -1.0 + if !info.Positive { + targetY = 1.0 + } + } + var target imgui.Vec2 + if info.Clockwise { + target.X = center.X - float32((float64(circleRadius)*targetX)*math.Cos(valueRadian)+(float64(circleRadius)*targetY)*math.Sin(valueRadian)) + target.Y = center.Y + float32((float64(circleRadius)*targetY)*math.Cos(valueRadian)-(float64(circleRadius)*targetX)*math.Sin(valueRadian)) + } else { + target.X = center.X + float32((float64(circleRadius)*targetX)*math.Cos(valueRadian)-(float64(circleRadius)*targetY)*math.Sin(valueRadian)) + target.Y = center.Y - float32((float64(circleRadius)*targetY)*math.Cos(valueRadian)+(float64(circleRadius)*targetX)*math.Sin(valueRadian)) + } + dl.AddLineV(center, target, lineColor, lineThickness) + imgui.EndTooltip() + } +} diff --git a/modding/hacked/editor/values/Unifier.go b/modding/hacked/editor/values/Unifier.go new file mode 100644 index 0000000..2e3173e --- /dev/null +++ b/modding/hacked/editor/values/Unifier.go @@ -0,0 +1,107 @@ +package values + +// Unifier can be used to verify whether a list of values has equal entries. +type Unifier struct { + state unifierState +} + +// NewUnifier returns a new instance. +func NewUnifier() Unifier { + return Unifier{state: unifierInitState{}} +} + +// UnifierFor returns a unifier for a single value. +func UnifierFor(value interface{}) Unifier { + unifier := NewUnifier() + unifier.Add(value) + return unifier +} + +// Add unifies the given value to the current state. +// The given value must be comparable. +func (u *Unifier) Add(value interface{}) { + u.state = u.state.add(value) +} + +// Unified returns the result of the unification. +// If all values that were added to the unifier were equal, then the first +// added value will be returned. Otherwise, nil will be returned. +func (u *Unifier) Unified() interface{} { + return u.state.unified() +} + +// IsUnique returns true if the unifier has received only equal values. +func (u Unifier) IsUnique() bool { + return u.state.isUnique() +} + +// IsEmpty returns true if the unifier has not received any value. +func (u Unifier) IsEmpty() bool { + return (u.state == nil) || u.state.isEmpty() +} + +type unifierState interface { + add(value interface{}) unifierState + unified() interface{} + isUnique() bool + isEmpty() bool +} + +type unifierInitState struct{} + +func (state unifierInitState) add(value interface{}) unifierState { + return unifierMatchedState{value: value} +} + +func (state unifierInitState) unified() interface{} { + return nil +} + +func (state unifierInitState) isUnique() bool { + return false +} + +func (state unifierInitState) isEmpty() bool { + return true +} + +type unifierMatchedState struct { + value interface{} +} + +func (state unifierMatchedState) add(value interface{}) unifierState { + if state.value == value { + return state + } + return unifierMismatchedState{} +} + +func (state unifierMatchedState) unified() interface{} { + return state.value +} + +func (state unifierMatchedState) isUnique() bool { + return true +} + +func (state unifierMatchedState) isEmpty() bool { + return false +} + +type unifierMismatchedState struct{} + +func (state unifierMismatchedState) add(value interface{}) unifierState { + return state +} + +func (state unifierMismatchedState) unified() interface{} { + return nil +} + +func (state unifierMismatchedState) isUnique() bool { + return false +} + +func (state unifierMismatchedState) isEmpty() bool { + return false +} diff --git a/modding/hacked/editor/values/Unifier_test.go b/modding/hacked/editor/values/Unifier_test.go new file mode 100644 index 0000000..c658f7c --- /dev/null +++ b/modding/hacked/editor/values/Unifier_test.go @@ -0,0 +1,84 @@ +package values_test + +import ( + "fmt" + "testing" + + "github.com/inkyblackness/hacked/editor/values" + + "github.com/stretchr/testify/assert" +) + +func TestUnifier_Integers(t *testing.T) { + tt := []struct { + values []int + expectedValue interface{} + expectedUnified bool + }{ + {[]int{1, 1, 1}, 1, true}, + {[]int{}, nil, false}, + {[]int{1, 2, 3}, nil, false}, + } + + for tn, tc := range tt { + u := values.NewUnifier() + for _, value := range tc.values { + u.Add(value) + } + resultValue := u.Unified() + resultUnified := u.IsUnique() + assert.Equal(t, tc.expectedValue, resultValue, fmt.Sprintf("Wrong result for test number %d", tn)) + assert.Equal(t, tc.expectedUnified, resultUnified, fmt.Sprintf("Wrong unified for test number %d", tn)) + } +} + +func TestUnifier_Strings(t *testing.T) { + tt := []struct { + values []string + expectedValue interface{} + expectedUnified bool + }{ + {[]string{"a", "a", "a"}, "a", true}, + {[]string{}, nil, false}, + {[]string{"a", "b", "c"}, nil, false}, + } + + for tn, tc := range tt { + u := values.NewUnifier() + for _, value := range tc.values { + u.Add(value) + } + resultValue := u.Unified() + resultUnified := u.IsUnique() + assert.Equal(t, tc.expectedValue, resultValue, fmt.Sprintf("Wrong result for test number %d", tn)) + assert.Equal(t, tc.expectedUnified, resultUnified, fmt.Sprintf("Wrong unified for test number %d", tn)) + } +} + +func TestUnifier_Types(t *testing.T) { + type simpleType struct { + value int + text string + } + tt := []struct { + values []simpleType + expectedValue interface{} + expectedUnified bool + }{ + {[]simpleType{{value: 1, text: "a"}, {value: 1, text: "a"}, {value: 1, text: "a"}}, simpleType{value: 1, text: "a"}, true}, + {[]simpleType{}, nil, false}, + {[]simpleType{{value: 1, text: "a"}, {value: 1, text: "a"}, {value: 2, text: "a"}}, nil, false}, + {[]simpleType{{value: 1, text: "a"}, {value: 1, text: "a"}, {value: 1, text: "b"}}, nil, false}, + } + + for tn, tc := range tt { + u := values.NewUnifier() + for _, value := range tc.values { + u.Add(value) + } + resultValue := u.Unified() + resultUnified := u.IsUnique() + assert.Equal(t, tc.expectedValue, resultValue, fmt.Sprintf("Wrong result for test number %d", tn)) + assert.Equal(t, tc.expectedUnified, resultUnified, fmt.Sprintf("Wrong unified for test number %d", tn)) + } +} diff --git a/modding/hacked/go.mod b/modding/hacked/go.mod new file mode 100644 index 0000000..7523cbc --- /dev/null +++ b/modding/hacked/go.mod @@ -0,0 +1,26 @@ +module github.com/inkyblackness/hacked + +go 1.25.0 + +require ( + github.com/asticode/go-astisub v0.12.1 + github.com/go-gl/gl v0.0.0-20210315015930-ae072cafe09d + github.com/go-gl/glfw v0.0.0-20210410170116-ea3d685f79fb + github.com/go-gl/mathgl v1.0.0 + github.com/inkyblackness/imgui-go/v3 v3.1.0 + github.com/lucasb-eyer/go-colorful v1.2.0 + github.com/sqweek/dialog v0.0.0-20200911184034-8a3d98e8211d + github.com/stretchr/testify v1.4.0 + golang.org/x/image v0.41.0 +) + +require ( + github.com/TheTitanrain/w32 v0.0.0-20200114052255-2654d97dbd3d // indirect + github.com/asticode/go-astikit v0.20.0 // indirect + github.com/asticode/go-astits v1.8.0 // indirect + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/text v0.37.0 // indirect + gopkg.in/yaml.v2 v2.2.8 // indirect +) diff --git a/modding/hacked/go.sum b/modding/hacked/go.sum new file mode 100644 index 0000000..162807f --- /dev/null +++ b/modding/hacked/go.sum @@ -0,0 +1,52 @@ +github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= +github.com/TheTitanrain/w32 v0.0.0-20200114052255-2654d97dbd3d h1:2xp1BQbqcDDaikHnASWpVZRjibOxu7y9LhAv04whugI= +github.com/TheTitanrain/w32 v0.0.0-20200114052255-2654d97dbd3d/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= +github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8= +github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= +github.com/asticode/go-astisub v0.12.1 h1:eAXddrpYLUcFAv+/PhibQbfhscZVT4dSG6UkPglF5g8= +github.com/asticode/go-astisub v0.12.1/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8= +github.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg= +github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-gl/gl v0.0.0-20210315015930-ae072cafe09d h1:o81yRlBATU4PRn97lydmsq8hTRNXI4wlR/VvUQhFRVY= +github.com/go-gl/gl v0.0.0-20210315015930-ae072cafe09d/go.mod h1:482civXOzJJCPzJ4ZOX/pwvXBWSnzD4OKMdH4ClKGbk= +github.com/go-gl/glfw v0.0.0-20210410170116-ea3d685f79fb h1:yMlfD+jmoG8wwBVLaFwtcgNBY207iWsYwtNC9u+T2yk= +github.com/go-gl/glfw v0.0.0-20210410170116-ea3d685f79fb/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/mathgl v1.0.0 h1:t9DznWJlXxxjeeKLIdovCOVJQk/GzDEL7h/h+Ro2B68= +github.com/go-gl/mathgl v1.0.0/go.mod h1:yhpkQzEiH9yPyxDUGzkmgScbaBVlhC06qodikEM0ZwQ= +github.com/inkyblackness/imgui-go/v3 v3.1.0 h1:KPUUEmt4I/Q1VGqBVpvHKOPZklltuHJ+WpTBZzUkNQc= +github.com/inkyblackness/imgui-go/v3 v3.1.0/go.mod h1:WdM5LZHuTrjd1d+yd2fA19t3mG538kJ6zOLB2sAXF6U= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sqweek/dialog v0.0.0-20200911184034-8a3d98e8211d h1:Chay1rwJnXxI27H+pzu7P81BKf647un9GOoRPTdXN18= +github.com/sqweek/dialog v0.0.0-20200911184034-8a3d98e8211d/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/image v0.0.0-20190321063152-3fc05d484e9f/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/modding/hacked/main.go b/modding/hacked/main.go new file mode 100644 index 0000000..38d28c6 --- /dev/null +++ b/modding/hacked/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "flag" + "fmt" + _ "image/gif" + _ "image/png" + "os" + "path/filepath" + "runtime/pprof" + "time" + + _ "golang.org/x/image/bmp" + + "github.com/inkyblackness/hacked/crash" + "github.com/inkyblackness/hacked/editor" + "github.com/inkyblackness/hacked/ui/native" +) + +var version string + +func main() { + scale := flag.Float64("scale", 1.0, "factor for scaling the UI (0.5 .. 10.0). 1080p displays should use default. 4K most likely 2.0.") + fontFile := flag.String("fontfile", "", "Path to font file (.TTF) to use instead of the default font. Useful for HiDPI displays.") + fontSize := flag.Float64("fontsize", 0.0, "Size of the font to use. If not specified, a default height will be used.") + cpuProfile := flag.String("cpuprofile", "", "write cpu profile to file") + flag.Parse() + var app editor.Application + app.FontFile = *fontFile + app.FontSize = float32(*fontSize) + app.GuiScale = float32(*scale) + if len(version) > 0 { + app.Version = version + } else { + app.Version = fmt.Sprintf("(manual build %v)", time.Now().Format("2006-01-02")) + } + + configDir, err := configDir() + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Failed to determine config dir: %v", err) + os.Exit(-1) + } + app.ConfigDir = configDir + + versionInfo := "InkyBlackness - HackEd - " + app.Version + defer crash.Handler(versionInfo) + + profileFin, err := initProfiling(*cpuProfile) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Failed to start CPU profiling: %v\n", err) + } + defer profileFin() + + err = native.Run(app.InitializeWindow, versionInfo, 30.0) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Failed to run application: %v\n", err) + } +} + +func initProfiling(filename string) (func(), error) { + if filename == "" { + return func() {}, nil + } + f, err := os.Create(filename) + if err != nil { + return func() {}, err + } + err = pprof.StartCPUProfile(f) + return func() { + pprof.StopCPUProfile() + _ = f.Close() + }, err +} + +func configDir() (string, error) { + base, err := os.UserConfigDir() + if err != nil { + return "", err + } + fullPath := filepath.Join(base, "InkyBlackness", "HackEd") + err = os.MkdirAll(fullPath, os.ModeDir|0750) + if err != nil { + return "", err + } + return fullPath, nil +} diff --git a/modding/hacked/ss1/StringError.go b/modding/hacked/ss1/StringError.go new file mode 100644 index 0000000..d002463 --- /dev/null +++ b/modding/hacked/ss1/StringError.go @@ -0,0 +1,10 @@ +package ss1 + +// StringError is a string-based type implementing the error interface. +// It is used to cover all errors based on static strings. +type StringError string + +// Error returns the string interpretation of the error. +func (err StringError) Error() string { + return string(err) +} diff --git a/modding/hacked/ss1/content/archive/Constants.go b/modding/hacked/ss1/content/archive/Constants.go new file mode 100644 index 0000000..be6e441 --- /dev/null +++ b/modding/hacked/ss1/content/archive/Constants.go @@ -0,0 +1,6 @@ +package archive + +const ( + // MaxLevels describes how many levels the world can hold. + MaxLevels = 16 +) diff --git a/modding/hacked/ss1/content/archive/GameState.go b/modding/hacked/ss1/content/archive/GameState.go new file mode 100644 index 0000000..20f02fd --- /dev/null +++ b/modding/hacked/ss1/content/archive/GameState.go @@ -0,0 +1,825 @@ +package archive + +import ( + "fmt" + "math" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/text" +) + +// GameStateSize specifies the byte count of a the GameState in an archive. +// Note that in the original archive.dat file, the resource only has 0x054D bytes. +// This does not matter much, as in the original engine the resource was ignored. +const GameStateSize = 0x0595 + +const ( + stateHackerNameSize = 20 + // InventoryWeaponSlots is the number of weapons available to the character. + InventoryWeaponSlots = 7 + weaponSlotsStartOffset = 0x048B + weaponSlotSize = 5 + + // GrenadeTypeCount is the number of grenades (explosives) available to the character. + GrenadeTypeCount = 7 + grenadeCountStartOffset = 0x0350 + grenadeTimerStartOffset = 0x04FF + grenadeTimerDefault = 70 + // GrenadeTimerMaximum is the maximum time that can be set for a grenade in game. + GrenadeTimerMaximum = 571 + + // PatchTypeCount is the number of patches available to the character. + PatchTypeCount = 7 + patchCountStartOffset = 0x0349 + + // AmmoTypeCount is the number of types of ammo available to the character. + AmmoTypeCount = 15 + ammoFullClipCountStartOffset = 0x032B + ammoExtraRoundsCountStartOffset = 0x033A + + // GeneralInventorySlotCount is the number of general inventory slots available. + GeneralInventorySlotCount = 14 + generalInventorySlotStartOffset = 0x007A + + // HardwareTypeCount is the number of available hardware. + HardwareTypeCount = 15 + hardwareVersionStartOffset = 0x0309 + hardwareStatusStartOffset = 0x04AE + + // SoftwareOffenseTypeCount is the number of available offense software. + SoftwareOffenseTypeCount = 7 + // SoftwareDefenseTypeCount is the number of available defense software. + SoftwareDefenseTypeCount = 3 + // SoftwareOneshotTypeCount is the number of available one-shot software. + SoftwareOneshotTypeCount = 4 + + installedOffenseSoftwareStartOffset = 0x0318 + installedDefenseSoftwareStartOffset = 0x031F + installedOneshotSoftwareStartOffset = 0x0322 + + engineTicksPerSecond = 280 + secondsPerMinute = 60 + engineTicksPerMinute = secondsPerMinute * engineTicksPerSecond + cyberspaceMinTime = 90 * engineTicksPerSecond + cyberspaceMaxTime = 30 * secondsPerMinute * engineTicksPerSecond + + // EMailCount is the number of available EMail status slots. + EMailCount = 47 + // FragmentCount is the number of available fragment (data) status slots. + FragmentCount = 23 + // PossibleLogsPerLevel is the number of logs slots available per level. + PossibleLogsPerLevel = 16 + // LevelsWithLogs is the number of levels that can hold logs. + LevelsWithLogs = 14 + // LogCount is the total number of logs available. + LogCount = PossibleLogsPerLevel * LevelsWithLogs + + messageStatusReceived byte = 0x80 + messageStatusRead byte = 0x40 + + messageStatusStartOffset = 0x0357 + emailStatusStartOffset = messageStatusStartOffset + logStatusStartOffset = emailStatusStartOffset + EMailCount + fragmentStatusStartOffset = logStatusStartOffset + LogCount + + // BooleanVarCount is the number of available boolean variables. + BooleanVarCount = 512 + booleanVarStartOffset = 0x00B6 + // IntegerVarCount is the number of available integer variables. + IntegerVarCount = 64 + integerVarStartOffset = 0x00F6 +) + +// GameState contains all the necessary information for the current progress of the game. +// It is a combination of world information, character information, and configuration values. +type GameState struct { + *interpreters.Instance +} + +// InventoryWeaponSlot describes one entry for weapons in the inventory. +type InventoryWeaponSlot struct { + Index int + State *GameState +} + +func freeWeaponSlot() []byte { + return []byte{0xFF, 0x00, 0x00, 0x00, 0x00} +} + +func (slot InventoryWeaponSlot) rawData() []byte { + if (slot.Index < 0) || (slot.Index >= InventoryWeaponSlots) { + return freeWeaponSlot() + } + startIndex := weaponSlotsStartOffset + (weaponSlotSize * slot.Index) + return slot.State.Raw()[startIndex : startIndex+weaponSlotSize] +} + +// IsInUse returns true if the slot is currently holding a weapon. +func (slot InventoryWeaponSlot) IsInUse() bool { + return slot.rawData()[0] != 0xFF +} + +// SetFree clears the weapon slot. +func (slot InventoryWeaponSlot) SetFree() { + rawData := slot.rawData() + copy(rawData, freeWeaponSlot()) +} + +// SetInUse inserts the given weapon in this slot. Other fields are reset to zero. +func (slot InventoryWeaponSlot) SetInUse(subclass object.Subclass, objType object.Type) { + rawData := slot.rawData() + copy(rawData, freeWeaponSlot()) + rawData[0] = byte(subclass) + rawData[1] = byte(objType) +} + +// Triple returns the type information of the weapon in this slot. +func (slot InventoryWeaponSlot) Triple() object.Triple { + rawData := slot.rawData() + return object.TripleFrom(int(object.ClassGun), int(rawData[0]), int(rawData[1])) +} + +// WeaponState returns the two state values. Their interpretation is subclass specific. +func (slot InventoryWeaponSlot) WeaponState() (byte, byte) { + rawData := slot.rawData() + return rawData[2], rawData[3] +} + +// SetWeaponState updates the two state values. +func (slot InventoryWeaponSlot) SetWeaponState(a, b byte) { + rawData := slot.rawData() + rawData[2] = a + rawData[3] = b +} + +// InventoryGrenade describes properties of grenades by type. +type InventoryGrenade struct { + Index int + State *GameState +} + +func (grenade InventoryGrenade) isValid() bool { + return (grenade.Index >= 0) && (grenade.Index < GrenadeTypeCount) && (grenade.State != nil) +} + +// Count returns how many explosives the character is carrying. +func (grenade InventoryGrenade) Count() int { + if !grenade.isValid() { + return 0 + } + return int(grenade.State.Raw()[grenadeCountStartOffset+grenade.Index]) +} + +// SetCount updates the amount of explosives the character is carrying. Invalid values are clamped. +func (grenade InventoryGrenade) SetCount(value int) { + if !grenade.isValid() { + return + } + count := byte(value) + if value < 0 { + count = 0 + } else if value > math.MaxUint8 { + count = math.MaxUint8 + } + + grenade.State.Raw()[grenadeCountStartOffset+grenade.Index] = count +} + +// GrenadeTimerSetting is the value for explosive timers. +type GrenadeTimerSetting uint16 + +// InSeconds returns the amount in unit of seconds. +func (setting GrenadeTimerSetting) InSeconds() float32 { + return float32(setting) / 10.0 +} + +func (grenade InventoryGrenade) rawTimer() []byte { + if !grenade.isValid() { + return []byte{0x00, 0x00} + } + startIndex := grenadeTimerStartOffset + (2 * grenade.Index) + return grenade.State.Raw()[startIndex : startIndex+2] +} + +// TimerSetting returns the generic timer used for this type of grenade. +func (grenade InventoryGrenade) TimerSetting() GrenadeTimerSetting { + raw := grenade.rawTimer() + return GrenadeTimerSetting((uint16(raw[1]) << 8) | (uint16(raw[0]) << 0)) +} + +// SetTimerSetting updates the generic timer used for this type of grenade. +func (grenade InventoryGrenade) SetTimerSetting(value GrenadeTimerSetting) { + raw := grenade.rawTimer() + raw[0] = byte(value >> 0) + raw[1] = byte(value >> 8) +} + +// PatchState describes properties of patches by type. +type PatchState struct { + Index int + State *GameState +} + +func (patch PatchState) isValid() bool { + return (patch.Index >= 0) && (patch.Index < PatchTypeCount) && (patch.State != nil) +} + +// Count returns how many patches the character is carrying. +func (patch PatchState) Count() int { + if !patch.isValid() { + return 0 + } + return int(patch.State.Raw()[patchCountStartOffset+patch.Index]) +} + +// SetCount updates the amount of patches the character is carrying. Invalid values are clamped. +func (patch PatchState) SetCount(value int) { + if !patch.isValid() { + return + } + count := byte(value) + if value < 0 { + count = 0 + } else if value > math.MaxUint8 { + count = math.MaxUint8 + } + + patch.State.Raw()[patchCountStartOffset+patch.Index] = count +} + +// InventoryAmmo describes properties of ammo by type. +type InventoryAmmo struct { + Index int + State *GameState +} + +func (ammo InventoryAmmo) isValid() bool { + return (ammo.Index >= 0) && (ammo.Index < AmmoTypeCount) && (ammo.State != nil) +} + +// FullClipCount returns how many full clips the character is carrying. +func (ammo InventoryAmmo) FullClipCount() int { + if !ammo.isValid() { + return 0 + } + return int(ammo.State.Raw()[ammoFullClipCountStartOffset+ammo.Index]) +} + +// SetFullClipCount updates the amount of full clips the character is carrying. Invalid values are clamped. +func (ammo InventoryAmmo) SetFullClipCount(value int) { + if !ammo.isValid() { + return + } + count := byte(value) + if value < 0 { + count = 0 + } else if value > math.MaxUint8 { + count = math.MaxUint8 + } + + ammo.State.Raw()[ammoFullClipCountStartOffset+ammo.Index] = count +} + +// ExtraRoundsCount returns how many extra rounds the character is carrying. +func (ammo InventoryAmmo) ExtraRoundsCount() int { + if !ammo.isValid() { + return 0 + } + return int(ammo.State.Raw()[ammoExtraRoundsCountStartOffset+ammo.Index]) +} + +// SetExtraRoundsCount updates the amount of extra rounds the character is carrying. Invalid values are clamped. +func (ammo InventoryAmmo) SetExtraRoundsCount(value int) { + if !ammo.isValid() { + return + } + count := byte(value) + if value < 0 { + count = 0 + } else if value > math.MaxUint8 { + count = math.MaxUint8 + } + + ammo.State.Raw()[ammoExtraRoundsCountStartOffset+ammo.Index] = count +} + +// GeneralInventorySlot describes properties of a general inventory slot. +type GeneralInventorySlot struct { + Index int + State *GameState +} + +func (slot GeneralInventorySlot) isValid() bool { + return (slot.Index >= 0) && (slot.Index < GeneralInventorySlotCount) && (slot.State != nil) +} + +func (slot GeneralInventorySlot) rawID() []byte { + if !slot.isValid() { + return []byte{0x00, 0x00} + } + startIndex := generalInventorySlotStartOffset + (2 * slot.Index) + return slot.State.Raw()[startIndex : startIndex+2] +} + +// ObjectID returns the ID of the object in this inventory slot. +func (slot GeneralInventorySlot) ObjectID() level.ObjectID { + raw := slot.rawID() + return level.ObjectID((uint16(raw[1]) << 8) | (uint16(raw[0]) << 0)) +} + +// SetObjectID updates the ID in this inventory slot. +func (slot GeneralInventorySlot) SetObjectID(id level.ObjectID) { + raw := slot.rawID() + raw[0] = byte(id >> 0) + raw[1] = byte(id >> 8) +} + +// HardwareState describes properties of hardware by type. +type HardwareState struct { + Index int + State *GameState +} + +func (hardware HardwareState) isValid() bool { + return (hardware.Index >= 0) && (hardware.Index < HardwareTypeCount) && (hardware.State != nil) +} + +// Version returns which version of the hardware is installed. Zero means not installed. +func (hardware HardwareState) Version() int { + if !hardware.isValid() { + return 0 + } + return int(hardware.State.Raw()[hardwareVersionStartOffset+hardware.Index]) +} + +// SetVersion updates the version the character has installed. +func (hardware HardwareState) SetVersion(value int) { + if !hardware.isValid() { + return + } + count := byte(value) + if value < 0 { + count = 0 + } else if value > math.MaxUint8 { + count = math.MaxUint8 + } + + hardware.State.Raw()[hardwareVersionStartOffset+hardware.Index] = count +} + +// IsActive returns whether the hardware is currently active. +func (hardware HardwareState) IsActive() bool { + if !hardware.isValid() { + return false + } + return hardware.State.Raw()[hardwareStatusStartOffset+hardware.Index] != 0 +} + +// SetActive updates the active state of a hardware. +func (hardware HardwareState) SetActive(on bool) { + if !hardware.isValid() { + return + } + var newValue byte + if on { + newValue = 1 + } + + hardware.State.Raw()[hardwareStatusStartOffset+hardware.Index] = newValue +} + +// VersionedSoftwareState describes software that is upgradeable (exists only once). +type VersionedSoftwareState struct { + startOffset int + limit int + + Index int + State *GameState +} + +func (sw VersionedSoftwareState) isValid() bool { + return (sw.Index >= 0) && (sw.Index < sw.limit) && (sw.State != nil) +} + +// Version returns the installed version number of the software. Zero means not installed. +func (sw VersionedSoftwareState) Version() int { + if !sw.isValid() { + return 0 + } + return int(sw.State.Raw()[sw.startOffset+sw.Index]) +} + +// SetVersion sets the installed version number of the software. +func (sw VersionedSoftwareState) SetVersion(value int) { + if !sw.isValid() { + return + } + count := byte(value) + if value < 0 { + count = 0 + } else if value > math.MaxUint8 { + count = math.MaxUint8 + } + sw.State.Raw()[sw.startOffset+sw.Index] = count +} + +// CountedSoftwareState describes software that is expended upon use. +type CountedSoftwareState struct { + startOffset int + limit int + + Index int + State *GameState +} + +func (sw CountedSoftwareState) isValid() bool { + return (sw.Index >= 0) && (sw.Index < sw.limit) && (sw.State != nil) +} + +// Count returns the available amount of the software. +func (sw CountedSoftwareState) Count() int { + if !sw.isValid() { + return 0 + } + return int(sw.State.Raw()[sw.startOffset+sw.Index]) +} + +// SetCount sets the installed amount of the software. +func (sw CountedSoftwareState) SetCount(value int) { + if !sw.isValid() { + return + } + count := byte(value) + if value < 0 { + count = 0 + } else if value > math.MaxUint8 { + count = math.MaxUint8 + } + sw.State.Raw()[sw.startOffset+sw.Index] = count +} + +// MessageState describes properties of message by index. +type MessageState struct { + startOffset int + limit int + + Index int + State *GameState +} + +func (message MessageState) isValid() bool { + return (message.Index >= 0) && (message.Index < message.limit) && (message.State != nil) +} + +// Received returns whether the given message was received. +func (message MessageState) Received() bool { + return message.hasFlag(messageStatusReceived) +} + +// SetReceived marks the receive status of the message. +func (message MessageState) SetReceived(value bool) { + message.setFlag(messageStatusReceived, value) +} + +// Read returns whether the given message was read. +func (message MessageState) Read() bool { + return message.hasFlag(messageStatusRead) +} + +// SetRead marks the read status of the message. +func (message MessageState) SetRead(value bool) { + message.setFlag(messageStatusRead, value) +} + +func (message MessageState) hasFlag(flag byte) bool { + if !message.isValid() { + return false + } + return (message.State.Raw()[message.startOffset+message.Index] & flag) != 0 +} + +func (message MessageState) setFlag(flag byte, on bool) { + if !message.isValid() { + return + } + offset := message.startOffset + message.Index + newState := message.State.Raw()[offset] + newState &= ^flag + if on { + newState |= flag + } + message.State.Raw()[offset] = newState +} + +var gameStateDesc = interpreters.New(). + With("Difficulty: Combat", 0x0015, 1).As(interpreters.RangedValue(0, 3)). + With("Difficulty: Mission", 0x0016, 1).As(interpreters.RangedValue(0, 3)). + With("Difficulty: Puzzle", 0x0017, 1).As(interpreters.RangedValue(0, 3)). + With("Difficulty: Cyber", 0x0018, 1).As(interpreters.RangedValue(0, 3)). + With("Game time", 0x001C, 4).As(interpreters.SpecialValue("Internal")). + With("Current Level", 0x0039, 1).As(interpreters.RangedValue(0, 15)). + With("Health", 0x009C, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) string { + return fmt.Sprintf("%.2f%%%%", float64(value*100)/255.0) + })). + With("Cyberspace integrity", 0x009D, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) string { + return fmt.Sprintf("%.2f%%%%", float64(value*100)/255.0) + })). + With("Health regeneration per minute", 0x009E, 2).As(interpreters.RangedValue(0, 1000)). + With("Power", 0x00AC, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) string { + return fmt.Sprintf("%.2f%%%%", float64(value*100)/255.0) + })). + With("Power Usage", 0x00AD, 1).As(interpreters.RangedValue(0, 0xFF)). + With("Out-of-power flag", 0x00AF, 1).As(interpreters.EnumValue( + map[uint32]string{ + 0: "Powered", + 1: "Out-of-power", + })). + With("Cyberspace base time", 0x00B2, 4).As(interpreters.FormattedRangedValue(cyberspaceMinTime, cyberspaceMaxTime, + func(value int) string { + minutes := value / engineTicksPerMinute + return fmt.Sprintf("%2dm %2.2fs", minutes, float64(value-(minutes*engineTicksPerMinute))/(engineTicksPerSecond)) + })). + With("Fatigue regeneration", 0x0181, 2).As(interpreters.RangedValue(0, 400)). + With("Fatigue regeneration base", 0x0183, 2).As(interpreters.RangedValue(0, 400)). + With("Fatigue regeneration max", 0x0185, 2).As(interpreters.RangedValue(0, 400)). + With("Accuracy", 0x0187, 1).As(interpreters.RangedValue(0, 255)). + With("Shield absorb rate", 0x0188, 1).As(interpreters.RangedValue(0, 255)). + With("Hacker Position X", 0x053F, 4).As(interpreters.FormattedRangedValue(0x010000, 0x3FFFFF, + func(value int) string { + return fmt.Sprintf("%2.03f", float32(value)/0x010000) + })). + With("Hacker Position Y", 0x0543, 4).As(interpreters.FormattedRangedValue(0x010000, 0x3FFFFF, + func(value int) string { + return fmt.Sprintf("%2.03f", float32(value)/0x010000) + })). + With("Hacker Position Z", 0x0547, 4).As(interpreters.FormattedRangedValue(0x010000, 0x1FFFFF, + func(value int) string { + return fmt.Sprintf("%2.03f tile(s)", float32(value)/0x010000) + })). + With("Hacker Yaw", 0x054B, 4).As(interpreters.RotationValue(0, edmsFullCircle())) + +func edmsFullCircle() int64 { + full := math.Pi * 2 * float64(0x10000) + return int64(full) - 1 +} + +// NewGameState returns a GameState instance for given raw data. +func NewGameState(raw []byte) *GameState { + return &GameState{Instance: gameStateDesc.For(raw)} +} + +// IsSavegame returns true if the state describes one during a running game. +func (state *GameState) IsSavegame() bool { + // Picking the right property is tricky. Most properties could be pre-initialized + // by an archive as well. + // For now, assume the "game time" will not be touched by initial archives. + // A possible other approach would be to take the "version" field, which + // is always set when a savegame is created, but is zero in the original. Yet, + // it is not clear whether or not a (pedantic) engine would respect this field and + // ignore data when it's zero. + return state.Get("Game time") > 0 +} + +// IsDefaulting returns true if the state describes an archive from which the engine +// should initialize the state for a new game. +func (state *GameState) IsDefaulting() bool { + return state.Get("Hacker Position X") == 0 +} + +// HackerName returns the name interpreted with given codepage. +func (state *GameState) HackerName(cp text.Codepage) string { + return cp.Decode(state.Raw()[:stateHackerNameSize]) +} + +// SetHackerName stores the given name using given codepage, up to the internal available amount of bytes. +func (state *GameState) SetHackerName(name string, cp text.Codepage) { + raw := state.Raw() + var zeroName [stateHackerNameSize]byte + copy(raw[:stateHackerNameSize], zeroName[:]) + copy(raw[:stateHackerNameSize], cp.Encode(name)) + raw[stateHackerNameSize-1] = 0 +} + +// CurrentLevel returns the number of the level hacker is currently in. +func (state GameState) CurrentLevel() int { + return int(state.Get("Current Level")) +} + +// HackerMapPosition returns the rough X/Y location on the map. +func (state GameState) HackerMapPosition() (level.Coordinate, level.Coordinate) { + x := state.Get("Hacker Position X") + y := state.Get("Hacker Position Y") + return level.CoordinateAt(byte(x>>16), byte(x>>8)), level.CoordinateAt(byte(y>>16), byte(y>>8)) +} + +// BooleanVar returns the state of the boolean variable at given index. Unsupported indices return 0. +func (state GameState) BooleanVar(index int) bool { + if (index < 0) || (index >= BooleanVarCount) { + return false + } + return state.Raw()[booleanVarStartOffset+(index/8)]&(0x01<<(uint(index%8))) != 0 +} + +// SetBooleanVar sets the state of the boolean variable at given index. Unsupported indices are ignored. +func (state *GameState) SetBooleanVar(index int, set bool) { + if (index < 0) || (index >= BooleanVarCount) { + return + } + byteIndex := booleanVarStartOffset + (index / 8) + bitMask := byte(0x01 << uint(index%8)) + temp := state.Raw()[byteIndex] + temp &= ^bitMask + if set { + temp |= bitMask + } + state.Raw()[byteIndex] = temp +} + +// IntegerVar returns the value of the integer variable at given index. Unsupported indices return 0. +func (state *GameState) IntegerVar(index int) int16 { + if (index < 0) || (index >= IntegerVarCount) { + return 0 + } + startOffset := integerVarStartOffset + (2 * index) + val := uint16(state.Raw()[startOffset+1])<<8 | uint16(state.Raw()[startOffset+0]) + return int16(val) +} + +// SetIntegerVar sets the value of the integer variable at given index. Unsupported indices are ignored. +func (state *GameState) SetIntegerVar(index int, value int16) { + if (index < 0) || (index >= IntegerVarCount) { + return + } + startOffset := integerVarStartOffset + (2 * index) + state.Raw()[startOffset+0] = byte((value >> 0) & 0xFF) + state.Raw()[startOffset+1] = byte((value >> 8) & 0xFF) +} + +// InventoryWeaponSlot returns an accessor for the identified weapon slot. +// Index should be within the range of [0..InventoryWeaponSlots[. +func (state *GameState) InventoryWeaponSlot(index int) InventoryWeaponSlot { + return InventoryWeaponSlot{ + Index: index, + State: state, + } +} + +// InventoryGrenade returns an accessor for the identified grenade type index. +// Index should be within the range of [0..GrenadeTypeCount[ . +func (state *GameState) InventoryGrenade(index int) InventoryGrenade { + return InventoryGrenade{ + Index: index, + State: state, + } +} + +// PatchState returns an accessor for the identified patch type index. +// Index should be within the range of [0..PatchTypeCount[ . +func (state *GameState) PatchState(index int) PatchState { + return PatchState{ + Index: index, + State: state, + } +} + +// InventoryAmmo returns an accessor for the identified ammo type index. +// Index should be within the range of [0..AmmoTypeCount[ . +func (state *GameState) InventoryAmmo(index int) InventoryAmmo { + return InventoryAmmo{ + Index: index, + State: state, + } +} + +// GeneralInventorySlot returns an accessor for the identified general slot. +// Index should be within the range of [0..GeneralInventorySlotCount[ . +func (state *GameState) GeneralInventorySlot(index int) GeneralInventorySlot { + return GeneralInventorySlot{ + Index: index, + State: state, + } +} + +// HardwareState returns an accessor for the identified hardware type index. +// Index should be within the range of [0..HardwareTypeCount[ . +func (state *GameState) HardwareState(index int) HardwareState { + return HardwareState{ + Index: index, + State: state, + } +} + +// VersionedOffenseSoftwareState returns an accessor for the identified versioned software status. +// Index should be within the range of [0..SoftwareOffenseTypeCount[ . +func (state *GameState) VersionedOffenseSoftwareState(index int) VersionedSoftwareState { + return VersionedSoftwareState{ + startOffset: installedOffenseSoftwareStartOffset, + limit: SoftwareOffenseTypeCount, + Index: index, + State: state, + } +} + +// VersionedDefenseSoftwareState returns an accessor for the identified versioned software status. +// Index should be within the range of [0..SoftwareDefenseTypeCount[ . +func (state *GameState) VersionedDefenseSoftwareState(index int) VersionedSoftwareState { + return VersionedSoftwareState{ + startOffset: installedDefenseSoftwareStartOffset, + limit: SoftwareDefenseTypeCount, + Index: index, + State: state, + } +} + +// OneshotSoftwareState returns an accessor for the identified software status. +// Index should be within the range of [0..SoftwareOneshotTypeCount[ . +func (state *GameState) OneshotSoftwareState(index int) CountedSoftwareState { + return CountedSoftwareState{ + startOffset: installedOneshotSoftwareStartOffset, + limit: SoftwareOneshotTypeCount, + Index: index, + State: state, + } +} + +// EMailState returns an accessor for the identified EMail status. +// Index should be within the range of [0..EMailCount[ . +func (state *GameState) EMailState(index int) MessageState { + return MessageState{ + startOffset: emailStatusStartOffset, + limit: EMailCount, + Index: index, + State: state, + } +} + +// LogState returns an accessor for the identified log status. +// Index should be within the range of [0..LogCount[ . +func (state *GameState) LogState(index int) MessageState { + return MessageState{ + startOffset: logStatusStartOffset, + limit: LogCount, + Index: index, + State: state, + } +} + +// FragmentState returns an accessor for the identified fragment status. +// Index should be within the range of [0..FragmentCount[ . +func (state *GameState) FragmentState(index int) MessageState { + return MessageState{ + startOffset: fragmentStatusStartOffset, + limit: FragmentCount, + Index: index, + State: state, + } +} + +// ZeroGameStateData returns the state block reset for default engine behaviour. +func ZeroGameStateData() []byte { + return make([]byte, GameStateSize) +} + +// DefaultGameState returns a GameState that can be used for any mission. +func DefaultGameState() *GameState { + data := ZeroGameStateData() + state := NewGameState(data) + + state.Set("Health", 212) + state.Set("Cyberspace integrity", 255) + state.Set("Health regeneration per minute", 0) + state.Set("Power", 255) + state.Set("Cyberspace base time", 30*60*engineTicksPerSecond) + state.Set("Shield absorb rate", 0) + state.Set("Fatigue regeneration", 0) + state.Set("Fatigue regeneration base", 100) + state.Set("Fatigue regeneration max", 400) + state.Set("Accuracy", 100) + + for i := 0; i < InventoryWeaponSlots; i++ { + state.InventoryWeaponSlot(i).SetFree() + } + for i := 0; i < GrenadeTypeCount; i++ { + state.InventoryGrenade(i).SetTimerSetting(grenadeTimerDefault) + } + + for index, info := range engineBooleanVariables { + if info.InitValue == nil { + continue + } + state.SetBooleanVar(index, *info.InitValue != 0) + } + for index, info := range engineIntegerVariables { + if info.InitValue == nil { + continue + } + state.SetIntegerVar(index, *info.InitValue) + } + + return state +} diff --git a/modding/hacked/ss1/content/archive/IsSavegame.go b/modding/hacked/ss1/content/archive/IsSavegame.go new file mode 100644 index 0000000..41b66b0 --- /dev/null +++ b/modding/hacked/ss1/content/archive/IsSavegame.go @@ -0,0 +1,28 @@ +package archive + +import ( + "io/ioutil" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +// IsSavegame returns true for resources that most likely identify a savegame. +// This function must be called with a resource from ID 0x0FA1. +func IsSavegame(res resource.BlockProvider) bool { + if res == nil { + return false + } + if res.BlockCount() < 1 { + return false + } + dataReader, err := res.Block(0) + if err != nil { + return false + } + data, err := ioutil.ReadAll(dataReader) + if err != nil { + return false + } + state := NewGameState(data) + return state.IsSavegame() +} diff --git a/modding/hacked/ss1/content/archive/IsSavegame_test.go b/modding/hacked/ss1/content/archive/IsSavegame_test.go new file mode 100644 index 0000000..249f51c --- /dev/null +++ b/modding/hacked/ss1/content/archive/IsSavegame_test.go @@ -0,0 +1,69 @@ +package archive_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/resource" +) + +func TestIsSavegameTrueForActualSavegame(t *testing.T) { + stateData := make([]byte, archive.GameStateSize) + stateData[0x001C+2] = 0x10 + res := resource.Resource{ + Properties: resource.Properties{ + Compressed: false, + ContentType: resource.Archive, + Compound: false, + }, + Blocks: resource.BlocksFrom([][]byte{stateData}), + } + + result := archive.IsSavegame(res) + assert.True(t, result) +} + +func TestIsSavegameFalseForWrongResourceContent(t *testing.T) { + res := resource.Resource{ + Properties: resource.Properties{ + Compressed: false, + ContentType: resource.Archive, + Compound: true, + }, + Blocks: resource.BlocksFrom([][]byte{}), + } + + result := archive.IsSavegame(res) + assert.False(t, result) +} + +func TestIsSavegameFalseForTooShortData(t *testing.T) { + res := resource.Resource{ + Properties: resource.Properties{ + Compressed: false, + ContentType: resource.Archive, + Compound: true, + }, + Blocks: resource.BlocksFrom([][]byte{make([]byte, 0x10)}), + } + + result := archive.IsSavegame(res) + assert.False(t, result) +} + +func TestIsSavegameFalseForZeroData(t *testing.T) { + stateData := make([]byte, archive.GameStateSize) + res := resource.Resource{ + Properties: resource.Properties{ + Compressed: false, + ContentType: resource.Archive, + Compound: true, + }, + Blocks: resource.BlocksFrom([][]byte{stateData}), + } + + result := archive.IsSavegame(res) + assert.False(t, result) +} diff --git a/modding/hacked/ss1/content/archive/Variables.go b/modding/hacked/ss1/content/archive/Variables.go new file mode 100644 index 0000000..91e06eb --- /dev/null +++ b/modding/hacked/ss1/content/archive/Variables.go @@ -0,0 +1,253 @@ +package archive + +import "math" + +// GameVariableLimits describes limits of a game variable. +type GameVariableLimits struct { + Minimum int16 + Maximum int16 +} + +// GameVariableInfo describes a variable in the game state of the archive. +type GameVariableInfo struct { + // InitValue is nil if the initial value is system dependent. + InitValue *int16 + // Name is the short identifier for the variable. + Name string + // Description is an optional text with further information on the variable. + Description string + // Limits may be provided to describe the range of possible values. + Limits *GameVariableLimits + // ValueNames may be set for enumerated values. + ValueNames map[int16]string + // Hardcoded variables are fixed for a new game. + Hardcoded bool +} + +// ResetValueInt returns the expected reset value for integer variables. +func (info GameVariableInfo) ResetValueInt() int16 { + if info.InitValue == nil { + return 0 + } + return *info.InitValue +} + +// ResetValueBool returns the expected reset value for boolean variables. +func (info GameVariableInfo) ResetValueBool() bool { + if info.InitValue == nil { + return false + } + return *info.InitValue != 0 +} + +// GameVariableInfoProvider provides lookup functionality for integer and boolean variables. +type GameVariableInfoProvider interface { + IntegerVariable(index int) GameVariableInfo + BooleanVariable(index int) GameVariableInfo +} + +// GameVariableInfoFor returns a new instance with given name. +func GameVariableInfoFor(name string) GameVariableInfo { + return GameVariableInfo{Name: name} +} + +// At returns an information based on the current one, with the given initial value. +func (info GameVariableInfo) At(value int16) GameVariableInfo { + info.InitValue = &value + return info +} + +// HardcodedConfig returns an information that is marked as being a hardcoded initialized configuration value. +func (info GameVariableInfo) HardcodedConfig() GameVariableInfo { + info.Hardcoded = true + return info +} + +// HardcodedAt returns an information that is marked as being a hardcoded variable with given initial value. +func (info GameVariableInfo) HardcodedAt(value int16) GameVariableInfo { + info.InitValue = &value + info.Hardcoded = true + return info +} + +// Enumerated returns an information with given value names. +func (info GameVariableInfo) Enumerated(names map[int16]string) GameVariableInfo { + info.ValueNames = names + return info +} + +// Boolean returns an enumerated information with No/Yes as possible values. +func (info GameVariableInfo) Boolean() GameVariableInfo { + return info.Enumerated(map[int16]string{ + 0: "No", + 1: "Yes", + }) +} + +// LimitedBy returns an information with given minimum and maximum values. +func (info GameVariableInfo) LimitedBy(min, max int16) GameVariableInfo { + info.Limits = &GameVariableLimits{ + Minimum: min, + Maximum: max, + } + return info +} + +// DescribedAs returns an information with given text as description. +func (info GameVariableInfo) DescribedAs(text string) GameVariableInfo { + info.Description = text + return info +} + +// GameVariables is a lookup map for information on game variables. +type GameVariables map[int]GameVariableInfo + +// Lookup returns the information for given index. If the index is not known, nil is returned. +func (vars GameVariables) Lookup(index int) *GameVariableInfo { + info, known := vars[index] + if !known { + return nil + } + return &info +} + +const ( + securityValueDescription = "The current security value is re-calculated whenever a level is loaded up." + randomCodeDescription = "If both codes are equal at the start of a new game, they will be randomized." + highscoreCodeDescription = "The highscore values are combined as one 32-bit integer, used for the MFD game MCOM." +) + +var engineIntegerVariables = GameVariables{ + 2: GameVariableInfoFor("Plastique explosion counter").At(0).LimitedBy(0, 100). + DescribedAs("This value is incremented for each exploding plastique."), + + 9: GameVariableInfoFor("Plot counter").At(0), + + 13: GameVariableInfoFor("Difficulty: Mission").HardcodedConfig().LimitedBy(0, 3), + 14: GameVariableInfoFor("Difficulty: Cyber").HardcodedConfig().LimitedBy(0, 3), + 15: GameVariableInfoFor("Difficulty: Combat").HardcodedConfig().LimitedBy(0, 3), + + 16: GameVariableInfoFor("Security Value: Level 0").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 17: GameVariableInfoFor("Security Value: Level 1").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 18: GameVariableInfoFor("Security Value: Level 2").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 19: GameVariableInfoFor("Security Value: Level 3").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 20: GameVariableInfoFor("Security Value: Level 4").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 21: GameVariableInfoFor("Security Value: Level 5").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 22: GameVariableInfoFor("Security Value: Level 6").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 23: GameVariableInfoFor("Security Value: Level 7").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 24: GameVariableInfoFor("Security Value: Level 8").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 25: GameVariableInfoFor("Security Value: Level 9").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 26: GameVariableInfoFor("Security Value: Level 10").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 27: GameVariableInfoFor("Security Value: Level 11").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 28: GameVariableInfoFor("Security Value: Level 12").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + 29: GameVariableInfoFor("Security Value: Level 13").HardcodedAt(0).LimitedBy(0, 1000).DescribedAs(securityValueDescription), + + 30: GameVariableInfoFor("Difficulty: Puzzle").HardcodedConfig().LimitedBy(0, 3), + 31: GameVariableInfoFor("Random Code 1").At(0).DescribedAs(randomCodeDescription), + 32: GameVariableInfoFor("Random Code 2").At(0).DescribedAs(randomCodeDescription), + + 41: GameVariableInfoFor("Music Volume").HardcodedConfig().LimitedBy(0, 100), + 42: GameVariableInfoFor("Video Gamma").HardcodedConfig().LimitedBy(0, math.MaxInt16), + 43: GameVariableInfoFor("SFX Volume").HardcodedConfig().LimitedBy(0, 100), + 44: GameVariableInfoFor("Mouse Handedness").HardcodedConfig().Enumerated( + map[int16]string{ + 0: "Right Handed", + 1: "Left Handed", + }), + 45: GameVariableInfoFor("Game: Highscore (pt1)").At(0).DescribedAs(highscoreCodeDescription), + 46: GameVariableInfoFor("Game: Highscore (pt2)").At(0).DescribedAs(highscoreCodeDescription), + 47: GameVariableInfoFor("Double-Click Speed").HardcodedConfig().LimitedBy(0, math.MaxInt16), + 48: GameVariableInfoFor("Language").HardcodedConfig().Enumerated(map[int16]string{ + 0: "Default", + 1: "French", + 2: "German", + }), + 49: GameVariableInfoFor("Audiolog Volume").HardcodedConfig().LimitedBy(0, 100), + 50: GameVariableInfoFor("Screen Mode").HardcodedConfig(), + 51: GameVariableInfoFor("Joystick Sensitivity").At(0x100).LimitedBy(0, 0x100), + 52: GameVariableInfoFor("Show Fullscreen Icons").HardcodedConfig().Boolean(), + 53: GameVariableInfoFor("Audio Messages").HardcodedConfig().Enumerated(map[int16]string{ + 0: "Text Only", + 1: "Speech Only", + 2: "Text and Speech", + }), + 54: GameVariableInfoFor("Show Fullscreen Vitals").HardcodedConfig().Boolean(), + 55: GameVariableInfoFor("Show Map Notes").HardcodedConfig().Boolean(), + 56: GameVariableInfoFor("Game: Wing Level").At(0).LimitedBy(0, 100), + 57: GameVariableInfoFor("HUD Color Bank").HardcodedConfig().LimitedBy(0, 2), + 58: GameVariableInfoFor("Audio Channels").HardcodedConfig().Enumerated(map[int16]string{ + 0: "2", + 1: "4", + 2: "8", + }), +} + +var engineBooleanVariables = GameVariables{ + 0: GameVariableInfoFor("Always False").At(0). + DescribedAs("This variable should always stay at 'False'.\n" + + "Default conditions assume boolean var 0 is zero to not block.\n" + + "For example, default doors would all become locked if it were 'True'."), + + 10: GameVariableInfoFor("Status HW: Delta Launch Enable").At(0), + 11: GameVariableInfoFor("Status HW: Alpha Launch Enable").At(0), + 12: GameVariableInfoFor("Status HW: Beta Launch Enable").At(0), + 15: GameVariableInfoFor("Status HW: Beta Launched").At(0), + + 20: GameVariableInfoFor("Reactor on Destruct").At(0). + DescribedAs("Note: Rumble will stop if boolean var 152 is 'True'."), + + 94: GameVariableInfoFor("Unopenable door"). + DescribedAs("Doors that are 'broken beyond repair' or such."), + + 145: GameVariableInfoFor("On-Line Help"), + + 152: GameVariableInfoFor("Self-Destruct Rumble Stop").At(0). + DescribedAs("Disables rumble caused by boolean var 20."), + 153: GameVariableInfoFor("Four or more plastique exploded").At(0). + DescribedAs("Set to 'True' if integer variable 2 reached 4 (or higher)."), + + 231: GameVariableInfoFor("Unopenable door"). + DescribedAs("Doors that are 'broken beyond repair' or such."), + + 300: GameVariableInfoFor("New Message Flag").At(0), +} + +// EngineIntegerVariable returns a variable info for integer variables. +// If the given index is not used by the engine, nil is returned. +func EngineIntegerVariable(index int) *GameVariableInfo { + return engineIntegerVariables.Lookup(index) +} + +// EngineBooleanVariable returns a variable info for boolean variables. +// If the given index is not used by the engine, nil is returned. +func EngineBooleanVariable(index int) *GameVariableInfo { + return engineBooleanVariables.Lookup(index) +} + +// IsRandomIntegerVariable returns true for the special variables that are randomized. +func IsRandomIntegerVariable(index int) bool { + return (index == 31) || (index == 32) +} + +// EngineVariables is a collector of engine-specific variable accessors. +type EngineVariables struct{} + +var unusedVar = GameVariableInfoFor("(unused)").At(0) + +// IntegerVariable returns a variable info for given index. +func (vars EngineVariables) IntegerVariable(index int) GameVariableInfo { + varInfo := EngineIntegerVariable(index) + if varInfo == nil { + return unusedVar + } + return *varInfo +} + +// BooleanVariable returns a variable info for given index. +func (vars EngineVariables) BooleanVariable(index int) GameVariableInfo { + varInfo := EngineBooleanVariable(index) + if varInfo == nil { + return unusedVar + } + return *varInfo +} diff --git a/modding/hacked/ss1/content/archive/doc.go b/modding/hacked/ss1/content/archive/doc.go new file mode 100644 index 0000000..b4a7ac9 --- /dev/null +++ b/modding/hacked/ss1/content/archive/doc.go @@ -0,0 +1,2 @@ +// Package archive contains all the information that is stored in an archive file. +package archive diff --git a/modding/hacked/ss1/content/archive/level/BaseInfo.go b/modding/hacked/ss1/content/archive/level/BaseInfo.go new file mode 100644 index 0000000..0a78582 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/BaseInfo.go @@ -0,0 +1,43 @@ +package level + +const ( + defaultMapXShift = 6 + defaultMapYShift = 6 + defaultMapHeightShift = 3 +) + +// BaseInfo describes the basic parameters of a level map. +type BaseInfo struct { + // XSize is the horizontal extent of the map (West to East). + XSize int32 + // YSize is the vertical extent of the map (South to North). + YSize int32 + // XShift is the base value of XSize (XSize <= 1 << XShift). + XShift int32 + // YShift is the base value of YSize (YSize <= 1 << YShift). + YShift int32 + // ZShift is the base value of the height of the map. + ZShift HeightShift + _ [4]byte + // Cyberspace indicates whether the level is cyberspace. + Cyberspace byte + _ [12]byte + // Scheduler contains base information about the schedule table of the level. + Scheduler SchedulerInfo +} + +// DefaultBaseInfo returns an initialized instance. +func DefaultBaseInfo(cyberspace bool) BaseInfo { + info := BaseInfo{ + XSize: 1 << defaultMapXShift, + YSize: 1 << defaultMapYShift, + XShift: defaultMapXShift, + YShift: defaultMapYShift, + ZShift: defaultMapHeightShift, + Scheduler: DefaultSchedulerInfo(), + } + if cyberspace { + info.Cyberspace = 1 + } + return info +} diff --git a/modding/hacked/ss1/content/archive/level/BaseInfo_test.go b/modding/hacked/ss1/content/archive/level/BaseInfo_test.go new file mode 100644 index 0000000..e89f688 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/BaseInfo_test.go @@ -0,0 +1,26 @@ +package level_test + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultBaseInfoSerialized(t *testing.T) { + buf := bytes.NewBuffer(nil) + info := level.DefaultBaseInfo(true) + _ = binary.Write(buf, binary.LittleEndian, &info) + + data := buf.Bytes() + assert.Equal(t, 58, len(data), "length mismatch") + assert.Equal(t, []byte{ + 0x40, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, data, "data mismatch") +} diff --git a/modding/hacked/ss1/content/archive/level/BlockPuzzleState.go b/modding/hacked/ss1/content/archive/level/BlockPuzzleState.go new file mode 100644 index 0000000..ff8bf35 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/BlockPuzzleState.go @@ -0,0 +1,75 @@ +package level + +// BlockPuzzleState describes the state of a block puzzle. +type BlockPuzzleState struct { + width int + height int + data []byte +} + +// NewBlockPuzzleState returns a new instance of a block puzzle state modifier. +// The returned instance works with the passed data slice directly. +func NewBlockPuzzleState(data []byte, height, width int) *BlockPuzzleState { + return &BlockPuzzleState{ + data: data, + width: width, + height: height} +} + +// CellValue returns the value of the identified cell. +func (state *BlockPuzzleState) CellValue(row, col int) int { + scratch := uint16(0) + + if state.positionOk(row, col) { + byteOffset, byteShift := state.byteOffset(row, col) + bitsAvailable := 8 - byteShift + + scratch = uint16(state.data[state.mappedByteIndex(byteOffset)]) >> byteShift + if bitsAvailable < 3 { + scratch |= uint16(state.data[state.mappedByteIndex(byteOffset+1)]) << bitsAvailable + } + } + + return int(scratch & 7) +} + +// SetCellValue sets the value of the identified cell. +func (state *BlockPuzzleState) SetCellValue(row, col int, value int) { + if state.positionOk(row, col) { + byteOffset, byteShift := state.byteOffset(row, col) + needsSecondByte := (8 - byteShift) < 3 + scratch := uint16(state.data[state.mappedByteIndex(byteOffset)]) + mask := uint16(0x0007) << byteShift + + if needsSecondByte { + scratch |= uint16(state.data[state.mappedByteIndex(byteOffset+1)]) << 8 + } + scratch = (scratch & ^mask) | (uint16(value) << byteShift) + state.data[state.mappedByteIndex(byteOffset)] = byte((scratch >> 0) & 0xFF) + if needsSecondByte { + state.data[state.mappedByteIndex(byteOffset+1)] = byte((scratch >> 8) & 0xFF) + } + } +} + +func (state *BlockPuzzleState) bitOffset(row, col int) int { + return (row*state.width + col) * 3 +} + +func (state *BlockPuzzleState) positionOk(row, col int) bool { + bitOffset := state.bitOffset(row, col) + + return (row < state.height) && (col < state.width) && (bitOffset <= (128 - 3)) +} + +func (state *BlockPuzzleState) byteOffset(row, col int) (byteOffset int, byteShift uint) { + bitOffset := state.bitOffset(row, col) + byteOffset = bitOffset / 8 + byteShift = uint(bitOffset % 8) + return +} + +func (state *BlockPuzzleState) mappedByteIndex(index int) int { + remainder := index % 4 + return 15 - (index - remainder + (3 - remainder)) +} diff --git a/modding/hacked/ss1/content/archive/level/BlockPuzzleState_test.go b/modding/hacked/ss1/content/archive/level/BlockPuzzleState_test.go new file mode 100644 index 0000000..0877cd3 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/BlockPuzzleState_test.go @@ -0,0 +1,131 @@ +package level_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + + "github.com/stretchr/testify/assert" +) + +func TestBlockPuzzleCellValueReturnsTheCellValue_A(t *testing.T) { + var data [16]byte + state := level.NewBlockPuzzleState(data[:], 1, 1) + + assert.Equal(t, 0, state.CellValue(0, 0)) +} + +func TestBlockPuzzleCellValueReturnsTheCellValue_B(t *testing.T) { + var data [16]byte + data[16-4] = 2 + state := level.NewBlockPuzzleState(data[:], 1, 1) + + assert.Equal(t, 2, state.CellValue(0, 0)) +} + +func TestBlockPuzzleCellValueReturnsTheCellValue_C(t *testing.T) { + var data [16]byte + data[16-4] = 0x18 + state := level.NewBlockPuzzleState(data[:], 2, 1) + + assert.Equal(t, 3, state.CellValue(1, 0)) +} + +func TestBlockPuzzleCellValueReturnsTheCellValue_D(t *testing.T) { + var data [16]byte + data[15] = 0x40 + data[8] = 0x01 + state := level.NewBlockPuzzleState(data[:], 4, 3) + + assert.Equal(t, 5, state.CellValue(3, 1)) +} + +func TestBlockPuzzleCellValueReturnsTheCellValue_E(t *testing.T) { + var data [16]byte + data[3] = 0x38 + state := level.NewBlockPuzzleState(data[:], 6, 7) + + assert.Equal(t, 7, state.CellValue(5, 6)) +} + +func TestBlockPuzzleCellValueReturnsZeroOutOfBounds_A(t *testing.T) { + var data [16]byte + for index := 0; index < len(data); index++ { + data[index] = 0xFF + } + state := level.NewBlockPuzzleState(data[:], 1, 1) + + assert.Equal(t, 0, state.CellValue(0, 1)) + assert.Equal(t, 0, state.CellValue(1, 0)) + assert.Equal(t, 0, state.CellValue(5, 5)) + assert.Equal(t, 0, state.CellValue(8, 8)) +} + +func TestBlockPuzzleCellValueReturnsZeroOutOfBounds_B(t *testing.T) { + var data [16]byte + for index := 0; index < len(data); index++ { + data[index] = 0xFF + } + state := level.NewBlockPuzzleState(data[:], 7, 6) + + assert.Equal(t, 0, state.CellValue(6, 7)) + assert.Equal(t, 0, state.CellValue(10, 10)) +} + +func TestBlockPuzzleCellValueReturnsZeroOutOfBounds_C(t *testing.T) { + var data [16]byte + for index := 0; index < len(data); index++ { + data[index] = 0xFF + } + state := level.NewBlockPuzzleState(data[:], 7, 7) + + assert.Equal(t, 0, state.CellValue(6, 0)) +} + +func TestBlockPuzzleSetCellValue_A(t *testing.T) { + var data [16]byte + state := level.NewBlockPuzzleState(data[:], 1, 1) + + state.SetCellValue(0, 0, 1) + assert.Equal(t, [16]byte{ + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00}, data) +} + +func TestBlockPuzzleSetCellValue_B(t *testing.T) { + var data [16]byte + state := level.NewBlockPuzzleState(data[:], 2, 2) + + state.SetCellValue(1, 0, 7) + assert.Equal(t, [16]byte{ + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0xC0, 0x01, 0x00, 0x00}, data) +} + +func TestBlockPuzzleSetCellValue_C(t *testing.T) { + var data [16]byte + state := level.NewBlockPuzzleState(data[:], 6, 7) + + state.SetCellValue(5, 6, 5) + assert.Equal(t, [16]byte{ + 0x00, 0x00, 0x00, 0x28, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00}, data) +} + +func TestBlockPuzzleSetCellValueIgnoredOutOfBounds(t *testing.T) { + var data [16]byte + state := level.NewBlockPuzzleState(data[:], 6, 7) + + state.SetCellValue(8, 8, 7) + assert.Equal(t, [16]byte{ + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00}, data) +} diff --git a/modding/hacked/ss1/content/archive/level/Constants.go b/modding/hacked/ss1/content/archive/level/Constants.go new file mode 100644 index 0000000..9bc0123 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Constants.go @@ -0,0 +1,20 @@ +package level + +const ( + // MapVersion is the version of the map structures. + MapVersion int32 = 11 + // ObjectVersion is the version of object structures. + ObjectVersion int32 = 27 + // SavefileVersion is the version of the save. + SavefileVersion int32 = 13 + + // InventorySize specifies how many items the inventory could hold. + InventorySize int = 20 + + // GradesOfShadow is the number of how much shadow (light) is possible. + GradesOfShadow = 16 +) + +var mapVersionValue = MapVersion +var objectVersionValue = ObjectVersion +var savefileVersionValue = SavefileVersion diff --git a/modding/hacked/ss1/content/archive/level/Coordinate.go b/modding/hacked/ss1/content/archive/level/Coordinate.go new file mode 100644 index 0000000..e1fb3b0 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Coordinate.go @@ -0,0 +1,22 @@ +package level + +// FineCoordinatesPerTileSide is the number of possible positions along an axis within a tile. +const FineCoordinatesPerTileSide = 256 + +// Coordinate describes a tile position in one dimension. +type Coordinate uint16 + +// CoordinateAt returns a coordinate with given parameters. +func CoordinateAt(tile, fine byte) Coordinate { + return Coordinate(uint16(tile)<<8 | uint16(fine)) +} + +// Tile returns the tile part of the coordinate. +func (coord Coordinate) Tile() byte { + return byte(coord >> 8) +} + +// Fine returns the fine part of the coordinate. +func (coord Coordinate) Fine() byte { + return byte(coord & 0xFF) +} diff --git a/modding/hacked/ss1/content/archive/level/CyberspaceFlightPull.go b/modding/hacked/ss1/content/archive/level/CyberspaceFlightPull.go new file mode 100644 index 0000000..cdf89d1 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/CyberspaceFlightPull.go @@ -0,0 +1,92 @@ +package level + +import "fmt" + +// CyberspaceFlightPull describes a directed pull of hacker in cyberspace. +type CyberspaceFlightPull byte + +// String returns a textual representation. +func (pull CyberspaceFlightPull) String() string { + switch pull { + case CyberspaceFlightPullNone: + return "None" + case CyberspaceFlightPullWeakEast: + return "Weak East" + case CyberspaceFlightPullWeakWest: + return "Weak West" + case CyberspaceFlightPullWeakNorth: + return "Weak North" + case CyberspaceFlightPullWeakSouth: + return "Weak South" + case CyberspaceFlightPullMediumEast: + return "Medium East" + case CyberspaceFlightPullMediumWest: + return "Medium West" + case CyberspaceFlightPullMediumNorth: + return "Medium North" + case CyberspaceFlightPullMediumSouth: + return "Medium South" + case CyberspaceFlightPullStrongEast: + return "Strong East" + case CyberspaceFlightPullStrongWest: + return "Strong West" + case CyberspaceFlightPullStrongNorth: + return "Strong North" + case CyberspaceFlightPullStrongSouth: + return "Strong South" + case CyberspaceFlightPullMediumCeiling: + return "Medium Ceiling" + case CyberspaceFlightPullMediumFloor: + return "Medium Floor" + case CyberspaceFlightPullStrongCeiling: + return "Strong Ceiling" + case CyberspaceFlightPullStrongFloor: + return "Strong Floor" + default: + return fmt.Sprintf("Unknown%02X", int(pull)) + } +} + +// CyberspaceFlightPull constants are listed below. +const ( + CyberspaceFlightPullNone CyberspaceFlightPull = 0 + CyberspaceFlightPullWeakEast CyberspaceFlightPull = 1 + CyberspaceFlightPullWeakWest CyberspaceFlightPull = 2 + CyberspaceFlightPullWeakNorth CyberspaceFlightPull = 3 + CyberspaceFlightPullWeakSouth CyberspaceFlightPull = 4 + CyberspaceFlightPullMediumEast CyberspaceFlightPull = 5 + CyberspaceFlightPullMediumWest CyberspaceFlightPull = 6 + CyberspaceFlightPullMediumNorth CyberspaceFlightPull = 7 + CyberspaceFlightPullMediumSouth CyberspaceFlightPull = 8 + CyberspaceFlightPullStrongEast CyberspaceFlightPull = 9 + CyberspaceFlightPullStrongWest CyberspaceFlightPull = 10 + CyberspaceFlightPullStrongNorth CyberspaceFlightPull = 11 + CyberspaceFlightPullStrongSouth CyberspaceFlightPull = 12 + CyberspaceFlightPullMediumCeiling CyberspaceFlightPull = 13 + CyberspaceFlightPullMediumFloor CyberspaceFlightPull = 14 + CyberspaceFlightPullStrongCeiling CyberspaceFlightPull = 15 + CyberspaceFlightPullStrongFloor CyberspaceFlightPull = 16 +) + +// CyberspaceFlightPulls returns all constants of CyberspaceFlightPull. +func CyberspaceFlightPulls() []CyberspaceFlightPull { + return []CyberspaceFlightPull{ + CyberspaceFlightPullNone, + CyberspaceFlightPullWeakEast, + CyberspaceFlightPullWeakWest, + CyberspaceFlightPullWeakNorth, + CyberspaceFlightPullWeakSouth, + CyberspaceFlightPullMediumEast, + CyberspaceFlightPullMediumWest, + CyberspaceFlightPullMediumNorth, + CyberspaceFlightPullMediumSouth, + CyberspaceFlightPullStrongEast, + CyberspaceFlightPullStrongWest, + CyberspaceFlightPullStrongNorth, + CyberspaceFlightPullStrongSouth, + CyberspaceFlightPullMediumCeiling, + CyberspaceFlightPullMediumFloor, + CyberspaceFlightPullStrongCeiling, + CyberspaceFlightPullStrongFloor, + } +} diff --git a/modding/hacked/ss1/content/archive/level/Direction.go b/modding/hacked/ss1/content/archive/level/Direction.go new file mode 100644 index 0000000..0ad3fd8 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Direction.go @@ -0,0 +1,41 @@ +package level + +// Direction is an enumeration of cardinal and ordinal directions. +// Cardinal directions describe sides, ordinal directions describe corners. +type Direction byte + +// Plus is a shortcut for dir.AsMask().Plus(other). +func (dir Direction) Plus(other Direction) DirectionMask { + return dir.AsMask().Plus(other) +} + +// AsMask returns the direction as a mask. +func (dir Direction) AsMask() DirectionMask { + return DirectionMask(1 << dir) +} + +// Offset returns a direction that you reach by offsetting this one by the given value. +// The direction goes in a circle, and both positive and negative offsets are allowed. +func (dir Direction) Offset(offset int) Direction { + return Direction((int(dir) + ((8 + (offset % 8)) % 8)) % 8) +} + +// DirectionMask is a bitfield combination of directions. +type DirectionMask byte + +// Plus adds the mask of the given direction to this mask and returns the result. +func (mask DirectionMask) Plus(dir Direction) DirectionMask { + return mask | dir.AsMask() +} + +// Direction constants are listed below. +const ( + DirNorth Direction = iota + DirNorthEast + DirEast + DirSouthEast + DirSouth + DirSouthWest + DirWest + DirNorthWest +) diff --git a/modding/hacked/ss1/content/archive/level/Direction_test.go b/modding/hacked/ss1/content/archive/level/Direction_test.go new file mode 100644 index 0000000..0c77b1a --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Direction_test.go @@ -0,0 +1,40 @@ +package level_test + +import ( + "fmt" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + + "github.com/stretchr/testify/assert" +) + +func TestDirectionOffset(t *testing.T) { + tt := []struct { + start level.Direction + offset int + expected level.Direction + }{ + {level.DirNorth, 0, level.DirNorth}, + {level.DirSouth, 0, level.DirSouth}, + + {level.DirNorth, 1, level.DirNorthEast}, + {level.DirNorth, -1, level.DirNorthWest}, + + {level.DirEast, 2, level.DirSouth}, + {level.DirWest, 3, level.DirNorthEast}, + + {level.DirNorth, 8, level.DirNorth}, + {level.DirNorth, -8, level.DirNorth}, + + {level.DirEast, 16, level.DirEast}, + {level.DirSouthEast, 4, level.DirNorthWest}, + + {level.DirSouth, -15, level.DirSouthWest}, + } + + for _, tc := range tt { + result := tc.start.Offset(tc.offset) + assert.Equal(t, tc.expected, result, fmt.Sprintf("Wrong for %d+%d", int(tc.start), tc.offset)) + } +} diff --git a/modding/hacked/ss1/content/archive/level/EmptyLevelData.go b/modding/hacked/ss1/content/archive/level/EmptyLevelData.go new file mode 100644 index 0000000..835bf6d --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/EmptyLevelData.go @@ -0,0 +1,94 @@ +package level + +import ( + "bytes" + "math/bits" + + "github.com/inkyblackness/hacked/ss1/content/archive/level/lvlids" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// EmptyLevelParameters contain the values to create an empty level. +type EmptyLevelParameters struct { + // XSize specifies the expected width, in tiles. 0 (or less) will default. + XSize int32 + // YSize specifies the expected height, in tiles. 0 (or less) will default. + YSize int32 + // Cyberspace indicates whether the level should be marked as cyberspace. + Cyberspace bool + // MapModifier is called to make initial changes to the map before serializing. + // This can be used to empty out starter tile on starter level. + MapModifier func(x, y int, entry *TileMapEntry) +} + +// EmptyLevelData returns an array of serialized data for an empty level. +func EmptyLevelData(param EmptyLevelParameters) [lvlids.PerLevel][]byte { + var levelData [lvlids.PerLevel][]byte + baseInfo := DefaultBaseInfo(param.Cyberspace) + if param.XSize > 0 { + baseInfo.XSize = param.XSize + baseInfo.XShift = int32(bits.Len32(uint32(param.XSize))) + if (1 << (baseInfo.XShift - 1)) == baseInfo.XSize { + baseInfo.XShift-- + } + } + if param.YSize > 0 { + baseInfo.YSize = param.YSize + baseInfo.YShift = int32(bits.Len32(uint32(param.YSize))) + if (1 << (baseInfo.YShift - 1)) == baseInfo.YSize { + baseInfo.YShift-- + } + } + + levelData[lvlids.MapVersionNumber] = encode(mapVersionValue) + levelData[lvlids.ObjectVersionNumber] = encode(objectVersionValue) + levelData[lvlids.Information] = encode(&baseInfo) + + tileMap := NewTileMap(1<> 5 +} + +// WithTextureRotations returns a floor info that specifies the given the rotation steps. +// The provided value is normalized to the valid range. +func (info FloorInfo) WithTextureRotations(value int) FloorInfo { + normalized := (4 + (value % 4)) % 4 + return FloorInfo(byte(info&^0x60) | byte(normalized)<<5) +} + +// HasHazard returns true if the hazard flag is set. +func (info FloorInfo) HasHazard() bool { + return (byte(info) & 0x80) != 0 +} + +// WithHazard returns a floor info with the hazard flag set according to given value. +func (info FloorInfo) WithHazard(value bool) FloorInfo { + var flag byte + if value { + flag = 0x80 + } + return FloorInfo(byte(info&^0x80) | flag) +} + +// CeilingInfo describes the properties of a ceiling. +type CeilingInfo byte + +// AbsoluteHeight returns the height (from minimum floor height zero) of the ceiling. +// Internally, this value is stored "from maximum ceiling height 32" down. +// The range is [1..TileHeightUnitMax]. +func (info CeilingInfo) AbsoluteHeight() TileHeightUnit { + value := info & 0x1F + return TileHeightUnit(byte(TileHeightUnitMax) - byte(value)) +} + +// WithAbsoluteHeight returns a ceiling info that specifies the given absolute height. +// Internally, this value is stored "from maximum ceiling height 32" down. +// The allowed range is [1..TileHeightUnitMax]. +func (info CeilingInfo) WithAbsoluteHeight(value TileHeightUnit) CeilingInfo { + return CeilingInfo(byte(info&0xE0) | (byte(TileHeightUnitMax-value) & 0x1F)) +} + +// TextureRotations returns the rotation steps to apply for the ceiling texture. Valid range: [0..3]. +func (info CeilingInfo) TextureRotations() int { + return int(info&0x60) >> 5 +} + +// WithTextureRotations returns a ceiling info that specifies the given the rotation steps. +// The provided value is normalized to the valid range. +func (info CeilingInfo) WithTextureRotations(value int) CeilingInfo { + normalized := (4 + (value % 4)) % 4 + return CeilingInfo(byte(info&^0x60) | byte(normalized)<<5) +} + +// HasHazard returns true if the hazard flag is set. +func (info CeilingInfo) HasHazard() bool { + return (byte(info) & 0x80) != 0 +} + +// WithHazard returns a ceiling info with the hazard flag set according to given value. +func (info CeilingInfo) WithHazard(value bool) CeilingInfo { + var flag byte + if value { + flag = 0x80 + } + return CeilingInfo(byte(info&^0x80) | flag) +} diff --git a/modding/hacked/ss1/content/archive/level/FloorCeilingInfo_test.go b/modding/hacked/ss1/content/archive/level/FloorCeilingInfo_test.go new file mode 100644 index 0000000..cd5813f --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/FloorCeilingInfo_test.go @@ -0,0 +1,41 @@ +package level_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" +) + +func TestFloorCeilingInfoPermutations(t *testing.T) { + for height := level.TileHeightUnit(0); height < level.TileHeightUnitMax; height++ { + for rotations := -4; rotations < 6; rotations++ { + for _, hazard := range []bool{false, true} { + floor1 := level.FloorInfo(0).WithAbsoluteHeight(height).WithTextureRotations(rotations).WithHazard(hazard) + floor2 := level.FloorInfo(0).WithTextureRotations(rotations).WithHazard(hazard).WithAbsoluteHeight(height) + floor3 := level.FloorInfo(0).WithHazard(hazard).WithAbsoluteHeight(height).WithTextureRotations(rotations) + + assert.Equal(t, floor1, floor2, "Floor1/2 mismatch") + assert.Equal(t, floor1, floor3, "Floor1/3 mismatch") + assert.Equal(t, floor2, floor3, "Floor2/3 mismatch") + + assert.Equal(t, height, floor1.AbsoluteHeight(), "floor.AbsoluteHeight mismatch") + assert.Equal(t, (4+rotations)%4, floor1.TextureRotations(), "floor.TextureRotations mismatch") + assert.Equal(t, hazard, floor1.HasHazard(), "floor.HasHazard mismatch") + + ceiling1 := level.CeilingInfo(0).WithAbsoluteHeight(height + 1).WithTextureRotations(rotations).WithHazard(hazard) + ceiling2 := level.CeilingInfo(0).WithTextureRotations(rotations).WithHazard(hazard).WithAbsoluteHeight(height + 1) + ceiling3 := level.CeilingInfo(0).WithHazard(hazard).WithAbsoluteHeight(height + 1).WithTextureRotations(rotations) + + assert.Equal(t, ceiling1, ceiling2, "Floor1/2 mismatch") + assert.Equal(t, ceiling1, ceiling3, "Floor1/3 mismatch") + assert.Equal(t, ceiling2, ceiling3, "Floor2/3 mismatch") + + assert.Equal(t, height+1, ceiling1.AbsoluteHeight(), "ceiling.AbsoluteHeight mismatch") + assert.Equal(t, (4+rotations)%4, ceiling1.TextureRotations(), "ceiling.TextureRotations mismatch") + assert.Equal(t, hazard, ceiling1.HasHazard(), "ceiling.HasHazard mismatch") + } + } + } +} diff --git a/modding/hacked/ss1/content/archive/level/HeightSemaphores.go b/modding/hacked/ss1/content/archive/level/HeightSemaphores.go new file mode 100644 index 0000000..593cc3c --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/HeightSemaphores.go @@ -0,0 +1,4 @@ +package level + +// HeightSemaphores is a placeholder for storing height transitions. +type HeightSemaphores [0x40]byte diff --git a/modding/hacked/ss1/content/archive/level/HeightShift.go b/modding/hacked/ss1/content/archive/level/HeightShift.go new file mode 100644 index 0000000..dacb168 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/HeightShift.go @@ -0,0 +1,39 @@ +package level + +import ( + "math" +) + +var tileHeights = []float64{32.0, 16.0, 8.0, 4.0, 2.0, 1.0, 0.5, 0.25} + +// HeightShift indicates the vertical scale of a level. +type HeightShift int32 + +// ValueFromTileHeight returns a floating point value in tiles based on a tile height. +func (shift HeightShift) ValueFromTileHeight(raw TileHeightUnit) (float32, error) { + return shift.valueFromScale(float32(raw), float64(TileHeightUnitMax)) +} + +// ValueFromObjectHeight returns a floating point value in tiles based on an object height. +func (shift HeightShift) ValueFromObjectHeight(raw HeightUnit) (float32, error) { + return shift.valueFromScale(float32(raw), float64(0x100)) +} + +// ValueToObjectHeight returns an object height value based on given floating point value. +func (shift HeightShift) ValueToObjectHeight(value float32) HeightUnit { + return HeightUnit(math.Min(0xFF, math.Max(0, shift.valueToScale(value, float64(0x100))))) +} + +func (shift HeightShift) valueFromScale(raw float32, scale float64) (float32, error) { + if (shift < 0) || (int(shift) >= len(tileHeights)) { + return 0.0, errInvalidHeightShift + } + return float32((float64(raw) * tileHeights[int(shift)]) / scale), nil +} + +func (shift HeightShift) valueToScale(value float32, scale float64) float64 { + if (shift < 0) || (int(shift) >= len(tileHeights)) { + return 0.0 + } + return (float64(value) * scale) / tileHeights[shift] +} diff --git a/modding/hacked/ss1/content/archive/level/HeightUnit.go b/modding/hacked/ss1/content/archive/level/HeightUnit.go new file mode 100644 index 0000000..5437014 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/HeightUnit.go @@ -0,0 +1,4 @@ +package level + +// HeightUnit describes a height in a range of 0..255. +type HeightUnit byte diff --git a/modding/hacked/ss1/content/archive/level/Level.go b/modding/hacked/ss1/content/archive/level/Level.go new file mode 100644 index 0000000..19f5454 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Level.go @@ -0,0 +1,581 @@ +package level + +import ( + "bytes" + "encoding/binary" + "io" + "io/ioutil" + + "github.com/inkyblackness/hacked/ss1/content/archive/level/lvlids" + "github.com/inkyblackness/hacked/ss1/content/archive/level/lvlobj" + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// Level is the complete structure defining all necessary data for a level. +type Level struct { + id int + localizer resource.Localizer + + resStart resource.ID + resEnd resource.ID + + baseInfo BaseInfo + tileMap TileMap + wallHeightsMap WallHeightsMap + textureAtlas TextureAtlas + + objectMainTable ObjectMainTable + objectCrossRefTable ObjectCrossReferenceTable + objectClassTables [object.ClassCount]ObjectClassTable + + textureAnimations []TextureAnimationEntry + surveillanceSources [SurveillanceObjectCount]ObjectID + surveillanceSurrogates [SurveillanceObjectCount]ObjectID + parameters Parameters +} + +// NewLevel returns a new instance. +func NewLevel(resourceBase resource.ID, id int, localizer resource.Localizer) *Level { + lvl := &Level{ + id: id, + + localizer: localizer, + + resStart: resourceBase.Plus(lvlids.PerLevel * id), + } + lvl.resEnd = lvl.resStart.Plus(lvlids.PerLevel) + + lvl.reloadBaseInfo() + lvl.reloadTileMap() + lvl.reloadTextureAtlas() + lvl.reloadObjectMainTable() + lvl.reloadObjectCrossRefTable() + lvl.reloadObjectClassTables() + lvl.reloadTextureAnimations() + lvl.reloadSurveillanceSources() + lvl.reloadSurveillanceSurrogates() + lvl.reloadParameters() + + return lvl +} + +// ID returns the identifier of the level. +func (lvl *Level) ID() int { + return lvl.id +} + +// InvalidateResources resets all internally cached data. +func (lvl *Level) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + if (id >= lvl.resStart) && (id < lvl.resEnd) { + lvl.onLevelResourceDataChanged(int(id.Value() - lvl.resStart.Value())) + } + } +} + +// Size returns the dimensions of the level. +func (lvl *Level) Size() (x, y int, z HeightShift) { + return int(lvl.baseInfo.XSize), int(lvl.baseInfo.YSize), lvl.baseInfo.ZShift +} + +// SetHeightShift changes the vertical scale of the level. +func (lvl *Level) SetHeightShift(value HeightShift) { + lvl.baseInfo.ZShift = value +} + +// TextureAnimations returns the list of animations. +func (lvl *Level) TextureAnimations() []TextureAnimationEntry { + return lvl.textureAnimations +} + +// SurveillanceSources returns the current sources of surveillance. +func (lvl *Level) SurveillanceSources() [SurveillanceObjectCount]ObjectID { + return lvl.surveillanceSources +} + +// SetSurveillanceSource sets a surveillance source. +func (lvl *Level) SetSurveillanceSource(index int, id ObjectID) { + if (index >= 0) && (index < SurveillanceObjectCount) { + lvl.surveillanceSources[index] = id + } +} + +// SurveillanceSurrogates returns the current surrogates of surveillance. +func (lvl *Level) SurveillanceSurrogates() [SurveillanceObjectCount]ObjectID { + return lvl.surveillanceSurrogates +} + +// SetSurveillanceSurrogate sets a surveillance source. +func (lvl *Level) SetSurveillanceSurrogate(index int, id ObjectID) { + if (index >= 0) && (index < SurveillanceObjectCount) { + lvl.surveillanceSurrogates[index] = id + } +} + +// IsCyberspace returns true if the level describes a cyberspace. +func (lvl *Level) IsCyberspace() bool { + return lvl.baseInfo.Cyberspace != 0 +} + +// Parameters returns the extra properties the level has. +func (lvl *Level) Parameters() *Parameters { + return &lvl.parameters +} + +// TextureAtlas returns the atlas for textures. +func (lvl *Level) TextureAtlas() TextureAtlas { + return lvl.textureAtlas +} + +// SetTextureAtlasEntry changes the entry of the atlas. +func (lvl *Level) SetTextureAtlasEntry(index int, textureIndex TextureIndex) { + if (index >= 0) && (index < len(lvl.textureAtlas)) { + lvl.textureAtlas[index] = textureIndex + } +} + +// Tile returns the tile entry at given position. +func (lvl *Level) Tile(pos TilePosition) *TileMapEntry { + return lvl.tileMap.Tile(pos, int(lvl.baseInfo.XShift)) +} + +// MapGridInfo returns the information necessary to draw a 2D map. +func (lvl *Level) MapGridInfo(pos TilePosition) (TileType, TileSlopeControl, WallHeights) { + tile := lvl.Tile(pos) + if tile == nil { + return TileTypeSolid, TileSlopeControlCeilingInverted, WallHeights{} + } + return tile.Type, tile.Flags.SlopeControl(), *lvl.wallHeightsMap.Tile(pos) +} + +// ObjectCapacity returns the number of objects that can be stored in this level. +func (lvl *Level) ObjectCapacity() int { + return lvl.objectMainTable.Capacity() +} + +// ObjectClassStats returns the number of used and total possible entries of given class. +func (lvl *Level) ObjectClassStats(class object.Class) (active, capacity int) { + if int(class) >= len(lvl.objectClassTables) { + return 0, 0 + } + objectClassTable := lvl.objectClassTables[class] + return objectClassTable.AllocatedCount(), objectClassTable.Capacity() +} + +// ForEachObject iterates over all active objects and calls the given handler. +func (lvl *Level) ForEachObject(handler func(ObjectID, ObjectMainEntry)) { + tableSize := len(lvl.objectMainTable) + if tableSize > 0 { + id := ObjectID(lvl.objectMainTable[0].CrossReferenceTableIndex) + for (id > 0) && (int(id) < tableSize) { + entry := lvl.objectMainTable[id] + handler(id, entry) + id = entry.Next + } + } +} + +// HasRoomForObjectOf returns true if the level can hold one object of given class. +func (lvl *Level) HasRoomForObjectOf(class object.Class) bool { + classActive, classCapacity := lvl.ObjectClassStats(class) + levelActive := lvl.objectMainTable.AllocatedCount() + levelCapacity := lvl.objectMainTable.Capacity() + return (levelActive < levelCapacity) && (classActive < classCapacity) +} + +// NewObject attempts to allocate a new object for given class. +// The created object has no place in the world and must be placed. +// Returns 0 and an error if not possible. +func (lvl *Level) NewObject(class object.Class) (ObjectID, error) { + if int(class) >= len(lvl.objectClassTables) { + return 0, errInvalidClass + } + classTable := lvl.objectClassTables[class] + classIndex := classTable.Allocate() + if classIndex == 0 { + return 0, errNoMoreRoomForClass + } + id := lvl.objectMainTable.Allocate() + if id == 0 { + classTable.Release(classIndex) + return 0, errNoMoreRoomForObjects + } + obj := &lvl.objectMainTable[id] + classEntry := &classTable[classIndex] + classEntry.ObjectID = id + obj.ClassTableIndex = int16(classIndex) + obj.Class = class + + lvl.addCrossReferenceTo(id, obj, offMapReferencePosition()) + + return id, nil +} + +// UpdateObjectLocation updates the reference table between object and tiles based on its current location. +func (lvl *Level) UpdateObjectLocation(id ObjectID) { + obj := lvl.Object(id) + if (obj == nil) || (obj.InUse == 0) { + return + } + lvl.removeCrossReferences(int(obj.CrossReferenceTableIndex), func(entry ObjectCrossReferenceEntry) int { return int(entry.NextTileForObj) }) + lvl.addCrossReferenceTo(id, obj, obj.TilePosition()) +} + +// DelObject removes the identified object from the level. +func (lvl *Level) DelObject(id ObjectID) { + obj := lvl.Object(id) + if (obj == nil) || (obj.InUse == 0) { + return + } + lvl.removeCrossReferences(int(obj.CrossReferenceTableIndex), func(entry ObjectCrossReferenceEntry) int { return int(entry.NextTileForObj) }) + if int(obj.Class) < len(lvl.objectClassTables) { + lvl.objectClassTables[obj.Class].Release(int(obj.ClassTableIndex)) + } + lvl.objectMainTable.Release(id) +} + +func (lvl *Level) addCrossReferenceTo(id ObjectID, obj *ObjectMainEntry, pos TilePosition) { + newIndex := lvl.objectCrossRefTable.Allocate() + if newIndex == 0 { + return + } + entry := &lvl.objectCrossRefTable[newIndex] + entry.ObjectID = id + entry.SetTilePosition(pos) + + tile := lvl.Tile(pos) + if tile != nil { + entry.NextInTile = tile.FirstObjectIndex + tile.FirstObjectIndex = int16(newIndex) + } + if obj.CrossReferenceTableIndex != 0 { + entry.NextTileForObj = obj.CrossReferenceTableIndex + endIndex := obj.CrossReferenceTableIndex + for lvl.objectCrossRefTable[endIndex].NextTileForObj != obj.CrossReferenceTableIndex { + endIndex = lvl.objectCrossRefTable[endIndex].NextTileForObj + } + lvl.objectCrossRefTable[endIndex].NextTileForObj = int16(newIndex) + } else { + entry.NextTileForObj = int16(newIndex) + } + obj.CrossReferenceTableIndex = int16(newIndex) +} + +func (lvl *Level) removeCrossReferences(start int, next func(ObjectCrossReferenceEntry) int) { + index := start + for (index != 0) && (index < len(lvl.objectCrossRefTable)) { + entry := lvl.objectCrossRefTable[index] + + tile := lvl.Tile(entry.TilePosition()) + if tile != nil { + if tile.FirstObjectIndex == int16(index) { + tile.FirstObjectIndex = entry.NextInTile + } else { + otherIndex := tile.FirstObjectIndex + for otherIndex != 0 { + otherEntry := &lvl.objectCrossRefTable[otherIndex] + otherIndex = otherEntry.NextInTile + if otherEntry.NextInTile == int16(index) { + otherEntry.NextInTile = entry.NextInTile + } + } + } + } + + obj := lvl.Object(entry.ObjectID) + if obj != nil { + switch { + case entry.NextTileForObj == int16(start): + obj.CrossReferenceTableIndex = 0 + case obj.CrossReferenceTableIndex == int16(index): + obj.CrossReferenceTableIndex = entry.NextTileForObj + default: + otherIndex := obj.CrossReferenceTableIndex + for otherIndex != 0 { + otherEntry := &lvl.objectCrossRefTable[otherIndex] + otherIndex = otherEntry.NextTileForObj + if otherEntry.NextTileForObj == int16(index) { + otherEntry.NextTileForObj = entry.NextTileForObj + } + } + } + } + + lvl.objectCrossRefTable.Release(index) + + index = next(entry) + if index == start { + index = 0 + } + } +} + +// Object returns the main entry for the identified object. +func (lvl *Level) Object(id ObjectID) *ObjectMainEntry { + var entry *ObjectMainEntry + if (id > 0) && (int(id) < len(lvl.objectMainTable)) { + entry = &lvl.objectMainTable[id] + } + return entry +} + +// ObjectClassData returns the raw class data for the given object. +func (lvl *Level) ObjectClassData(obj *ObjectMainEntry) *interpreters.Instance { + if (obj == nil) || (obj.InUse == 0) { + return interpreters.New().For(nil) + } + if int(obj.Class) >= len(lvl.objectClassTables) { + return interpreters.New().For(nil) + } + classTable := lvl.objectClassTables[obj.Class] + if (obj.ClassTableIndex < 1) || (int(obj.ClassTableIndex) >= len(classTable)) { + return interpreters.New().For(nil) + } + + interpreterFactory := lvlobj.ForRealWorld + if lvl.IsCyberspace() { + interpreterFactory = lvlobj.ForCyberspace + } + return interpreterFactory(obj.Triple(), classTable[obj.ClassTableIndex].Data) +} + +// ObjectExtraData returns the raw extra data for the given object. +func (lvl *Level) ObjectExtraData(obj *ObjectMainEntry) *interpreters.Instance { + if (obj == nil) || (obj.InUse == 0) { + return interpreters.New().For(nil) + } + interpreterFactory := lvlobj.RealWorldExtra + if lvl.IsCyberspace() { + interpreterFactory = lvlobj.CyberspaceExtra + } + return interpreterFactory(obj.Triple(), obj.Extra[:]) +} + +// EncodeState returns a subset of encoded level data, which only includes +// data that is loaded (modified) by the level structure. +// For any data block that is not relevant, a zero length slice is returned. +func (lvl *Level) EncodeState() [lvlids.PerLevel][]byte { + var levelData [lvlids.PerLevel][]byte + + levelData[lvlids.Information] = encode(&lvl.baseInfo) + + levelData[lvlids.TextureAtlas] = encode(lvl.textureAtlas) + levelData[lvlids.TileMap] = encode(lvl.tileMap) + levelData[lvlids.ObjectMainTable] = encode(lvl.objectMainTable) + levelData[lvlids.ObjectCrossRefTable] = encode(lvl.objectCrossRefTable) + for class := 0; class < len(lvl.objectClassTables); class++ { + levelData[lvlids.ObjectClassTablesStart+class] = encode(lvl.objectClassTables[class]) + } + + levelData[lvlids.TextureAnimations] = encode(lvl.textureAnimations) + levelData[lvlids.SurveillanceSources] = encode(&lvl.surveillanceSources) + levelData[lvlids.SurveillanceSurrogates] = encode(&lvl.surveillanceSurrogates) + levelData[lvlids.Parameters] = encode(&lvl.parameters) + + return levelData +} + +func (lvl *Level) onLevelResourceDataChanged(id int) { + switch id { + case lvlids.Information: + lvl.reloadBaseInfo() + case lvlids.TextureAtlas: + lvl.reloadTextureAtlas() + case lvlids.TileMap: + lvl.reloadTileMap() + case lvlids.ObjectMainTable: + lvl.reloadObjectMainTable() + case lvlids.ObjectCrossRefTable: + lvl.reloadObjectCrossRefTable() + case lvlids.TextureAnimations: + lvl.reloadTextureAnimations() + case lvlids.SurveillanceSources: + lvl.reloadSurveillanceSources() + case lvlids.SurveillanceSurrogates: + lvl.reloadSurveillanceSurrogates() + case lvlids.Parameters: + lvl.reloadParameters() + } + if (id >= lvlids.ObjectClassTablesStart) && (id < (lvlids.ObjectClassTablesStart + len(lvl.objectClassTables))) { + lvl.reloadObjectClassTable(object.Class(id - lvlids.ObjectClassTablesStart)) + } +} + +func (lvl *Level) reloadBaseInfo() { + reader, err := lvl.reader(lvlids.Information) + if err != nil { + lvl.baseInfo = BaseInfo{} + return + } + err = binary.Read(reader, binary.LittleEndian, &lvl.baseInfo) + if err != nil { + lvl.baseInfo = BaseInfo{} + } +} + +func (lvl *Level) reloadTextureAtlas() { + reader, err := lvl.reader(lvlids.TextureAtlas) + if err != nil { + lvl.textureAtlas = nil + return + } + data, err := ioutil.ReadAll(reader) + if err != nil { + lvl.textureAtlas = nil + return + } + lvl.textureAtlas = make([]TextureIndex, len(data)/2) + err = binary.Read(bytes.NewReader(data), binary.LittleEndian, lvl.textureAtlas) + if err != nil { + lvl.textureAtlas = nil + } +} + +func (lvl *Level) reloadTileMap() { + lvl.tileMap = NewTileMap(1<> 4) +} + +// WithCeiling returns a new instance with the given ceiling value. +func (info LightDeltaInfo) WithCeiling(value int) LightDeltaInfo { + if (value < 0) || (value > 15) { + return info + } + return (info & 0x0F) | LightDeltaInfo(value<<4) +} + +// OfFloor returns the delta value for the floor. +func (info LightDeltaInfo) OfFloor() int { + return int(info & 0x0F) +} + +// WithFloor returns a new instance with the given floor value. +func (info LightDeltaInfo) WithFloor(value int) LightDeltaInfo { + if (value < 0) || (value > 15) { + return info + } + return (info & 0xF0) | LightDeltaInfo(value) +} diff --git a/modding/hacked/ss1/content/archive/level/LoopConfigEntry.go b/modding/hacked/ss1/content/archive/level/LoopConfigEntry.go new file mode 100644 index 0000000..4f9baa8 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/LoopConfigEntry.go @@ -0,0 +1,8 @@ +package level + +const ( + // LoopConfigEntryCount describes how many loop entries a level has. + LoopConfigEntryCount = 64 + // LoopConfigEntrySize describes the size, in bytes, of a loop config entry. + LoopConfigEntrySize = 15 +) diff --git a/modding/hacked/ss1/content/archive/level/MapNotes.go b/modding/hacked/ss1/content/archive/level/MapNotes.go new file mode 100644 index 0000000..df5d50e --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/MapNotes.go @@ -0,0 +1,9 @@ +package level + +const ( + // MapNotesSize is the size, in bytes, of the map notes resource. + MapNotesSize = 0x0800 +) + +// MapNotesPointer is an offset into the map notes resource. +type MapNotesPointer uint32 diff --git a/modding/hacked/ss1/content/archive/level/ObjectClassInfo.go b/modding/hacked/ss1/content/archive/level/ObjectClassInfo.go new file mode 100644 index 0000000..c6988f1 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectClassInfo.go @@ -0,0 +1,34 @@ +package level + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" +) + +// ObjectClassInfo contains meta information about a level object class in an archive. +type ObjectClassInfo struct { + DataSize int + EntryCount int +} + +var objectClassInfoList = []ObjectClassInfo{ + {DataSize: 2, EntryCount: 16}, + {DataSize: 0, EntryCount: 32}, + {DataSize: 34, EntryCount: 32}, + {DataSize: 6, EntryCount: 32}, + {DataSize: 0, EntryCount: 32}, + {DataSize: 1, EntryCount: 8}, + {DataSize: 3, EntryCount: 16}, + {DataSize: 10, EntryCount: 176}, + {DataSize: 10, EntryCount: 128}, + {DataSize: 24, EntryCount: 64}, + {DataSize: 8, EntryCount: 64}, + {DataSize: 4, EntryCount: 32}, + {DataSize: 22, EntryCount: 160}, + {DataSize: 15, EntryCount: 64}, + {DataSize: 40, EntryCount: 64}, +} + +// ObjectClassInfoFor returns the info entry for the corresponding object class. +func ObjectClassInfoFor(class object.Class) ObjectClassInfo { + return objectClassInfoList[int(class)] +} diff --git a/modding/hacked/ss1/content/archive/level/ObjectClassTable.go b/modding/hacked/ss1/content/archive/level/ObjectClassTable.go new file mode 100644 index 0000000..5b67378 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectClassTable.go @@ -0,0 +1,156 @@ +package level + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/serial" +) + +const ( + // ObjectClassEntryHeaderSize is the size, in bytes, of the header prefix for each class entry. + ObjectClassEntryHeaderSize = 6 +) + +// ObjectClassEntry describes an entry in a object-class specific list. +type ObjectClassEntry struct { + ObjectID ObjectID + Next int16 + Prev int16 + Data []byte +} + +// NewObjectClassEntry returns an instance with the given amount of bytes initialized for data. +func NewObjectClassEntry(dataSize int) *ObjectClassEntry { + return &ObjectClassEntry{ + Data: make([]byte, dataSize), + } +} + +// Code serializes the entry with given coder, including the data array. +func (entry *ObjectClassEntry) Code(coder serial.Coder) { + coder.Code(&entry.ObjectID) + coder.Code(&entry.Next) + coder.Code(&entry.Prev) + coder.Code(entry.Data) +} + +// Reset sets all members of the entry to zero, including all bytes of the data array. +func (entry *ObjectClassEntry) Reset() { + entry.ObjectID = 0 + entry.Next = 0 + entry.Prev = 0 + for i := 0; i < len(entry.Data); i++ { + entry.Data[i] = 0 + } +} + +// ObjectClassTable is a list of entries. +// +// The first entry is reserved for internal use. For the reserved entry, +// the Next field identifies the head of the single-linked free chain, +// and the ObjectID field identifies the head of the double-linked used chain. +type ObjectClassTable []ObjectClassEntry + +// DefaultObjectClassTable returns an initialized table for given object class. +func DefaultObjectClassTable(class object.Class) ObjectClassTable { + info := ObjectClassInfoFor(class) + table := make(ObjectClassTable, info.EntryCount) + table.AllocateData(info.DataSize) + table.Reset() + return table +} + +// AllocateData prepares each entry to be able to store the given amount of bytes. +// This function drops any previously assigned data. +func (table ObjectClassTable) AllocateData(size int) { + for i := 0; i < len(table); i++ { + table[i].Data = make([]byte, size) + } +} + +// Reset wipes the entire table and initializes all links. +// The bytes of the data members of each entry are reset to zero. +func (table ObjectClassTable) Reset() { + tableLen := len(table) + for i := 0; i < tableLen; i++ { + entry := &table[i] + entry.Reset() + entry.Next = int16(i + 1) + } + if tableLen > 0 { + table[tableLen-1].Next = 0 + } +} + +// Code serializes the table with the provided coder. +func (table ObjectClassTable) Code(coder serial.Coder) { + for i := 0; i < len(table); i++ { + table[i].Code(coder) + } +} + +// Capacity returns the number of how many objects the table can hold. +func (table ObjectClassTable) Capacity() int { + size := len(table) + if size < 2 { + return 0 + } + return size - 1 +} + +// AllocatedCount returns the number of how many objects are currently allocated in the table. +func (table ObjectClassTable) AllocatedCount() int { + capacity := table.Capacity() + if capacity == 0 { + return 0 + } + index := int(table[0].ObjectID) + count := 0 + for (index != 0) && (count < capacity) { + count++ + index = int(table[index].Next) + } + return count +} + +// Allocate attempts to reserve an entry in the table and returns the corresponding index. +// returns 0 if exhausted. +func (table ObjectClassTable) Allocate() int { + if len(table) < 2 { + return 0 + } + start := &table[0] + if start.Next == 0 { + return 0 + } + index := start.Next + entry := &table[index] + start.Next = entry.Next + + entry.Reset() + entry.Next = int16(start.ObjectID) + table[start.ObjectID].Prev = index + entry.Prev = 0 + start.ObjectID = ObjectID(index) + + return int(index) +} + +// Release frees the identified entry. +func (table ObjectClassTable) Release(index int) { + if (index < 1) || (index >= len(table)) { + return + } + start := &table[0] + entry := &table[index] + + if entry.Prev == 0 { + start.ObjectID = ObjectID(entry.Next) + } else { + table[entry.Prev].Next = entry.Next + } + table[entry.Next].Prev = entry.Prev + + entry.Reset() + entry.Next = start.Next + start.Next = int16(index) +} diff --git a/modding/hacked/ss1/content/archive/level/ObjectClassTable_test.go b/modding/hacked/ss1/content/archive/level/ObjectClassTable_test.go new file mode 100644 index 0000000..b957e81 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectClassTable_test.go @@ -0,0 +1,144 @@ +package level_test + +import ( + "bytes" + "crypto/rand" + "fmt" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewObjectClassEntrySetsData(t *testing.T) { + entry := level.NewObjectClassEntry(20) + assert.Equal(t, 20, len(entry.Data)) +} + +func TestObjectClassEntryCanBeSerializedWithCoder(t *testing.T) { + entry := level.NewObjectClassEntry(10) + buf := bytes.NewBuffer(nil) + encoder := serial.NewEncoder(buf) + encoder.Code(entry) + data := buf.Bytes() + assert.Equal(t, 10+6, len(data)) +} + +func TestObjectClassTableCanBeSerializedWithCoder(t *testing.T) { + table := level.ObjectClassTable([]level.ObjectClassEntry{*level.NewObjectClassEntry(5), *level.NewObjectClassEntry(5)}) + buf := bytes.NewBuffer(nil) + encoder := serial.NewEncoder(buf) + encoder.Code(table) + data := buf.Bytes() + assert.Equal(t, (5+6)*2, len(data)) +} + +func TestObjectClassTableCanAllocateData(t *testing.T) { + table := level.ObjectClassTable([]level.ObjectClassEntry{{}, {}}) + table.AllocateData(4) + assert.Equal(t, 4, len(table[0].Data)) + assert.Equal(t, 4, len(table[1].Data)) +} + +func TestObjectClassTableResetClearsEntryData(t *testing.T) { + table := level.ObjectClassTable([]level.ObjectClassEntry{aRandomObjectClassEntry(), aRandomObjectClassEntry()}) + table.Reset() + + for index, entry := range table { + expected := make([]byte, len(entry.Data)) + assert.Equal(t, expected, entry.Data, fmt.Sprintf("Data not zero for %d", index)) + } +} + +func TestObjectClassTableResetInitializesChainPointers(t *testing.T) { + table := level.ObjectClassTable([]level.ObjectClassEntry{aRandomObjectClassEntry(), aRandomObjectClassEntry(), aRandomObjectClassEntry()}) + table.Reset() + + start := table[0] + assert.Equal(t, int16(1), start.Next, "start.Next should be 1, the first free entry.") + assert.Equal(t, level.ObjectID(0), start.ObjectID, "start.ObjectID should be 0, the list is empty.") + free0 := table[1] + assert.Equal(t, int16(2), free0.Next, "free0.Next should be 2, the second free entry.") + free1 := table[2] + assert.Equal(t, int16(0), free1.Next, "free1.Next should be 0, the start entry.") +} + +func TestDefaultObjectClassTable(t *testing.T) { + table := level.DefaultObjectClassTable(0) + assert.Equal(t, 16, len(table), "16 entries for class 0 expected") + assert.Equal(t, 2, len(table[0].Data), "2 bytes for class 0 data expected") + assert.Equal(t, int16(1), table[0].Next, "free list expected to be initialized") +} + +func TestObjectClassTableAllocate(t *testing.T) { + tt := []int{0, 1, 2, 3, 100} + + for _, tc := range tt { + table := make(level.ObjectClassTable, tc) + table.Reset() + possible := tc - 1 + + if possible < 0 { + possible = 0 + } + assert.Equal(t, possible, table.Capacity(), "table should have proper capacity") + for attempt := 0; attempt < possible; attempt++ { + assert.Equal(t, attempt, table.AllocatedCount(), "table should have proper allocation count") + index := table.Allocate() + assert.NotEqual(t, 0, index, "could not allocate at attempt %d for size %d", attempt, tc) + } + assert.Equal(t, possible, table.AllocatedCount(), "table should be full") + last := table.Allocate() + assert.Equal(t, 0, last, "table was not exhausted although it should be") + } +} + +func TestObjectClassTableRelease(t *testing.T) { + stats := func(table level.ObjectClassTable) (used, free int) { + for next := int16(table[0].ObjectID); next != 0; next = table[next].Next { + used++ + } + for next := table[0].Next; next != 0; next = table[next].Next { + free++ + } + return + } + + table := make(level.ObjectClassTable, 10) + table.Reset() + var allocated []int + for i := 0; i < 4; i++ { + id := table.Allocate() + allocated = append(allocated, id) + } + used, free := stats(table) + require.Equal(t, 4, used, "invalid amount of used entries") + require.Equal(t, 5, free, "invalid amount of free entries") + + for _, id := range allocated { + table.Release(id) + } + + used, free = stats(table) + assert.Equal(t, 0, used, "invalid amount of used entries after release") + assert.Equal(t, 9, free, "invalid amount of free entries after release") + + table.Release(0) + table.Release(20) + for i := 0; i < len(table)-1; i++ { + id := table.Allocate() + assert.NotEqual(t, level.ObjectID(0), id, "should have been able to re-allocate") + } +} + +func aRandomObjectClassEntry() level.ObjectClassEntry { + decoder := serial.NewDecoder(rand.Reader) + var dataSize byte + decoder.Code(&dataSize) + entry := level.NewObjectClassEntry(int(dataSize) + 1) + decoder.Code(entry) + return *entry +} diff --git a/modding/hacked/ss1/content/archive/level/ObjectCrossReferenceTable.go b/modding/hacked/ss1/content/archive/level/ObjectCrossReferenceTable.go new file mode 100644 index 0000000..2870b77 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectCrossReferenceTable.go @@ -0,0 +1,103 @@ +package level + +const ( + // ObjectCrossReferenceEntrySize describes the size, in bytes, of a ObjectCrossReferenceEntry. + ObjectCrossReferenceEntrySize = 10 + + defaultObjectCrossReferenceEntryCount = 1600 +) + +func offMapReferencePosition() TilePosition { + return TilePosition{X: 0xFF, Y: 0} +} + +// ObjectCrossReferenceEntry links objects and tiles. +type ObjectCrossReferenceEntry struct { + TileX int16 + TileY int16 + + ObjectID ObjectID + NextInTile int16 + NextTileForObj int16 +} + +// TilePosition returns the position for that entry. +func (entry ObjectCrossReferenceEntry) TilePosition() TilePosition { + return TilePosition{ + X: byte(entry.TileX), + Y: byte(entry.TileY), + } +} + +// SetTilePosition sets the position for that entry. +func (entry *ObjectCrossReferenceEntry) SetTilePosition(pos TilePosition) { + if pos == offMapReferencePosition() { + entry.TileX = -1 + entry.TileY = 0 + } else { + entry.TileX = int16(pos.X) + entry.TileY = int16(pos.Y) + } +} + +// Reset clears the members of the entry. +func (entry *ObjectCrossReferenceEntry) Reset() { + *entry = ObjectCrossReferenceEntry{} +} + +// ObjectCrossReferenceTable is a list of entries. +// The first entry is reserved for internal use. For the reserved entry, +// The NextInTile member refers to the head of the single-linked free chain. +type ObjectCrossReferenceTable []ObjectCrossReferenceEntry + +// DefaultObjectCrossReferenceTable returns an initialized table with a default size. +func DefaultObjectCrossReferenceTable() ObjectCrossReferenceTable { + table := make(ObjectCrossReferenceTable, defaultObjectCrossReferenceEntryCount) + table.Reset() + return table +} + +// Reset wipes the entire table and initializes all links. +func (table ObjectCrossReferenceTable) Reset() { + tableLen := len(table) + for i := 0; i < tableLen; i++ { + entry := &table[i] + entry.Reset() + entry.NextInTile = int16(i + 1) + } + if tableLen > 0 { + table[tableLen-1].NextInTile = 0 + } +} + +// Allocate attempts to reserve a free entry in the table and return its index. +// Returns 0 if exhausted. +func (table ObjectCrossReferenceTable) Allocate() int { + if len(table) < 2 { + return 0 + } + start := &table[0] + if start.NextInTile == 0 { + return 0 + } + index := start.NextInTile + entry := &table[index] + start.NextInTile = entry.NextInTile + + entry.Reset() + + return int(index) +} + +// Release frees the entry with given index. +func (table ObjectCrossReferenceTable) Release(index int) { + if (index < 1) || (index >= len(table)) { + return + } + start := &table[0] + entry := &table[index] + + entry.Reset() + entry.NextInTile = start.NextInTile + start.NextInTile = int16(index) +} diff --git a/modding/hacked/ss1/content/archive/level/ObjectCrossReferenceTable_test.go b/modding/hacked/ss1/content/archive/level/ObjectCrossReferenceTable_test.go new file mode 100644 index 0000000..9b27879 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectCrossReferenceTable_test.go @@ -0,0 +1,114 @@ +package level_test + +import ( + "bytes" + "crypto/rand" + "encoding/binary" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestObjectCrossReferenceEntrySerializedSize(t *testing.T) { + var entry level.ObjectCrossReferenceEntry + size := binary.Size(&entry) + assert.Equal(t, level.ObjectCrossReferenceEntrySize, size) +} + +func TestObjectCrossReferenceTableCanBeSerializedWithCoder(t *testing.T) { + table := level.ObjectCrossReferenceTable([]level.ObjectCrossReferenceEntry{{}, {}}) + buf := bytes.NewBuffer(nil) + encoder := serial.NewEncoder(buf) + encoder.Code(table) + data := buf.Bytes() + assert.Equal(t, level.ObjectCrossReferenceEntrySize*2, len(data)) +} + +func TestObjectCrossReferenceTableResetInitializesChainPointers(t *testing.T) { + table := level.ObjectCrossReferenceTable([]level.ObjectCrossReferenceEntry{ + aRandomObjectCrossReferenceEntry(), aRandomObjectCrossReferenceEntry(), aRandomObjectCrossReferenceEntry()}) + table.Reset() + + start := table[0] + assert.Equal(t, level.ObjectID(0), start.ObjectID, "start.ObjectID should be 0, it is unused.") + assert.Equal(t, int16(1), start.NextInTile, "start.NextInTile should be 1, the first free.") + free0 := table[1] + assert.Equal(t, level.ObjectID(0), free0.ObjectID, "free0.ObjectID should be 0, it is unused.") + assert.Equal(t, int16(2), free0.NextInTile, "free0.NextInTile should be 2, the second free.") + free1 := table[2] + assert.Equal(t, level.ObjectID(0), free1.ObjectID, "free1.ObjectID should be 0, it is unused.") + assert.Equal(t, int16(0), free1.NextInTile, "free1.NextInTile should be 0, the start.") +} + +func TestDefaultObjectCrossReferenceTable(t *testing.T) { + table := level.DefaultObjectCrossReferenceTable() + + assert.Equal(t, 1600, len(table), "Wrong default size") + assert.Equal(t, int16(1), table[0].NextInTile, "start entry should point to first free.") +} + +func TestObjectCrossReferenceTableAllocate(t *testing.T) { + tt := []int{0, 1, 2, 3, 100} + + for _, tc := range tt { + table := make(level.ObjectCrossReferenceTable, tc) + table.Reset() + possible := tc - 1 + + for attempt := 0; attempt < possible; attempt++ { + index := table.Allocate() + assert.NotEqual(t, 0, index, "could not allocate at attempt %d for size %d", attempt, tc) + } + last := table.Allocate() + assert.Equal(t, 0, last, "table was not exhausted although it should be") + } +} + +func TestObjectCrossReferenceTableRelease(t *testing.T) { + stats := func(table level.ObjectCrossReferenceTable) (used, free int) { + for next := table[0].NextInTile; next != 0; next = table[next].NextInTile { + free++ + } + used = len(table) - 1 - free + return + } + + table := make(level.ObjectCrossReferenceTable, 10) + table.Reset() + var allocated []int + for i := 0; i < 4; i++ { + id := table.Allocate() + allocated = append(allocated, id) + } + used, free := stats(table) + require.Equal(t, 4, used, "invalid amount of used entries") + require.Equal(t, 5, free, "invalid amount of free entries") + + for _, id := range allocated { + table.Release(id) + } + + used, free = stats(table) + assert.Equal(t, 0, used, "invalid amount of used entries after release") + assert.Equal(t, 9, free, "invalid amount of free entries after release") + + table.Release(0) + table.Release(20) + for i := 0; i < len(table)-1; i++ { + id := table.Allocate() + assert.NotEqual(t, level.ObjectID(0), id, "should have been able to re-allocate") + } +} + +func aRandomObjectCrossReferenceEntry() level.ObjectCrossReferenceEntry { + decoder := serial.NewDecoder(rand.Reader) + var dataSize byte + decoder.Code(&dataSize) + var entry level.ObjectCrossReferenceEntry + decoder.Code(&entry) + return entry +} diff --git a/modding/hacked/ss1/content/archive/level/ObjectID.go b/modding/hacked/ss1/content/archive/level/ObjectID.go new file mode 100644 index 0000000..d4c9ce8 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectID.go @@ -0,0 +1,5 @@ +package level + +// ObjectID identifies one object in a level. +// It is also the index into the main object table. +type ObjectID int16 diff --git a/modding/hacked/ss1/content/archive/level/ObjectMainTable.go b/modding/hacked/ss1/content/archive/level/ObjectMainTable.go new file mode 100644 index 0000000..8089855 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectMainTable.go @@ -0,0 +1,160 @@ +package level + +import "github.com/inkyblackness/hacked/ss1/content/object" + +const ( + // ObjectMainEntrySize describes the size, in bytes, of a ObjectMainEntry. + ObjectMainEntrySize = 27 + + defaultObjectMainEntryCount = 872 +) + +// ObjectMainEntryModifier is a function that changes properties. +type ObjectMainEntryModifier func(*ObjectMainEntry) + +// ObjectMainEntry describes an object in the level. +type ObjectMainEntry struct { + InUse byte + + Class object.Class + Subclass object.Subclass + + ClassTableIndex int16 + CrossReferenceTableIndex int16 + Next ObjectID + Prev ObjectID + + X Coordinate + Y Coordinate + Z HeightUnit + XRotation RotationUnit + ZRotation RotationUnit + YRotation RotationUnit + + _ byte + + Type object.Type + + Hitpoints int16 + + Extra [4]byte +} + +// Triple returns the unique identifier of the entry. +func (entry ObjectMainEntry) Triple() object.Triple { + return object.TripleFrom(int(entry.Class), int(entry.Subclass), int(entry.Type)) +} + +// TilePosition returns the tile coordinates of the object. +func (entry ObjectMainEntry) TilePosition() TilePosition { + return TilePosition{X: entry.X.Tile(), Y: entry.Y.Tile()} +} + +// FinePosition returns the coordinates of the object within the tile. +func (entry ObjectMainEntry) FinePosition() FinePosition { + return FinePosition{X: entry.X.Fine(), Y: entry.Y.Fine()} +} + +// Reset clears the entry and resets all members. +func (entry *ObjectMainEntry) Reset() { + *entry = ObjectMainEntry{} +} + +// ObjectMainTable is a list of entries. +// The first entry is reserved for internal use. For the reserved entry, +// the Next field refers to the head of the single-linked free chain, +// and the CrossReferenceTableIndex refers to the head of the double-linked used chain. +type ObjectMainTable []ObjectMainEntry + +// DefaultObjectMainTable returns an initialized table with a default size. +func DefaultObjectMainTable() ObjectMainTable { + table := make(ObjectMainTable, defaultObjectMainEntryCount) + table.Reset() + return table +} + +// Capacity returns the number of how many objects the table can hold. +func (table ObjectMainTable) Capacity() int { + size := len(table) + if size < 2 { + return 0 + } + return size - 1 +} + +// AllocatedCount returns the number of how many objects are currently allocated in the table. +func (table ObjectMainTable) AllocatedCount() int { + capacity := table.Capacity() + if capacity == 0 { + return 0 + } + index := int(table[0].CrossReferenceTableIndex) + count := 0 + for (index != 0) && (count < capacity) { + count++ + index = int(table[index].Next) + } + return count +} + +// Reset wipes the entire table and initializes all links. +func (table ObjectMainTable) Reset() { + tableLen := len(table) + for i := 0; i < tableLen; i++ { + entry := &table[i] + entry.Reset() + entry.Next = ObjectID(i + 1) + } + if tableLen > 0 { + table[tableLen-1].Next = 0 + } +} + +// Allocate attempts to find an available entry in the table and activates it. +// Returns 0 if not possible (exhausted). +func (table ObjectMainTable) Allocate() ObjectID { + if len(table) < 2 { + return 0 + } + start := &table[0] + if start.Next == 0 { + return 0 + } + id := start.Next + entry := &table[id] + start.Next = entry.Next + + entry.Reset() + entry.Next = ObjectID(start.CrossReferenceTableIndex) + table[start.CrossReferenceTableIndex].Prev = id + entry.Prev = 0 + start.CrossReferenceTableIndex = int16(id) + + entry.InUse = 1 + + return id +} + +// Release deactivates the entry with given ID. +func (table ObjectMainTable) Release(id ObjectID) { + if (id < 1) || (int(id) >= len(table)) { + return + } + start := &table[0] + entry := &table[id] + + if entry.InUse == 0 { + return + } + + if entry.Prev == 0 { + start.CrossReferenceTableIndex = int16(entry.Next) + } else { + table[entry.Prev].Next = entry.Next + } + table[entry.Next].Prev = entry.Prev + + entry.Reset() + entry.Next = start.Next + start.Next = id +} diff --git a/modding/hacked/ss1/content/archive/level/ObjectMainTable_test.go b/modding/hacked/ss1/content/archive/level/ObjectMainTable_test.go new file mode 100644 index 0000000..d434e30 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/ObjectMainTable_test.go @@ -0,0 +1,116 @@ +package level_test + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestObjectMainEntrySerializedSize(t *testing.T) { + var entry level.ObjectMainEntry + size := binary.Size(&entry) + assert.Equal(t, level.ObjectMainEntrySize, size) +} + +func TestObjectMainTableCanBeSerializedWithCoder(t *testing.T) { + table := level.ObjectMainTable([]level.ObjectMainEntry{{}, {}}) + buf := bytes.NewBuffer(nil) + encoder := serial.NewEncoder(buf) + encoder.Code(table) + data := buf.Bytes() + assert.Equal(t, level.ObjectMainEntrySize*2, len(data)) +} + +func TestObjectMainTableResetClearsInUseFlags(t *testing.T) { + table := level.ObjectMainTable([]level.ObjectMainEntry{{InUse: 1}, {InUse: 1}}) + table.Reset() + + assert.Equal(t, byte(0), table[0].InUse, "table[0].InUse should be zero.") + assert.Equal(t, byte(0), table[1].InUse, "table[1].InUse should be zero.") +} + +func TestObjectMainTableInitializesLists(t *testing.T) { + table := level.ObjectMainTable([]level.ObjectMainEntry{{Next: 20, CrossReferenceTableIndex: 10}, {Next: 30}, {Next: 40}}) + table.Reset() + + assert.Equal(t, level.ObjectID(1), table[0].Next, "table[0].Next should be 1, the first free entry.") + assert.Equal(t, int16(0), table[0].CrossReferenceTableIndex, "table[0].CrossReferenceTableIndex should be 0, the used list is empty.") + assert.Equal(t, level.ObjectID(2), table[1].Next, "table[1].Next should be 2, the second free entry.") + assert.Equal(t, level.ObjectID(0), table[2].Next, "table[2].Next should be 0, the start entry.") +} + +func TestDefaultObjectMainTable(t *testing.T) { + table := level.DefaultObjectMainTable() + + assert.Equal(t, 872, len(table), "Table length mismatch") + assert.Equal(t, level.ObjectID(1), table[0].Next, "table[0].Next should be 1, the first free entry.") +} + +func TestObjectMainTableAllocate(t *testing.T) { + tt := []int{0, 1, 2, 3, 100} + + for _, tc := range tt { + table := make(level.ObjectMainTable, tc) + table.Reset() + possible := tc - 1 + + if possible < 0 { + possible = 0 + } + assert.Equal(t, possible, table.Capacity(), "table should have proper capacity") + for attempt := 0; attempt < possible; attempt++ { + assert.Equal(t, attempt, table.AllocatedCount(), "table should have expected allocation count") + id := table.Allocate() + assert.NotEqual(t, level.ObjectID(0), id, "could not allocate at attempt %d for size %d", attempt, tc) + } + assert.Equal(t, possible, table.AllocatedCount(), "table should be full") + last := table.Allocate() + assert.Equal(t, level.ObjectID(0), last, "table was not exhausted although it should be") + } +} + +func TestObjectMainTableRelease(t *testing.T) { + stats := func(table level.ObjectMainTable) (used, free int) { + for i := 1; i < len(table); i++ { + entry := &table[i] + if entry.InUse != 0 { + used++ + } else { + free++ + } + } + return + } + + table := make(level.ObjectMainTable, 10) + table.Reset() + var allocated []level.ObjectID + for i := 0; i < 4; i++ { + id := table.Allocate() + allocated = append(allocated, id) + } + used, free := stats(table) + require.Equal(t, 4, used, "invalid amount of used entries") + require.Equal(t, 5, free, "invalid amount of free entries") + + for _, id := range allocated { + table.Release(id) + } + + used, free = stats(table) + assert.Equal(t, 0, used, "invalid amount of used entries after release") + assert.Equal(t, 9, free, "invalid amount of free entries after release") + + table.Release(0) + table.Release(20) + for i := 0; i < len(table)-1; i++ { + id := table.Allocate() + assert.NotEqual(t, level.ObjectID(0), id, "should have been able to re-allocate") + } +} diff --git a/modding/hacked/ss1/content/archive/level/Parameters.go b/modding/hacked/ss1/content/archive/level/Parameters.go new file mode 100644 index 0000000..39867ad --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Parameters.go @@ -0,0 +1,29 @@ +package level + +const ( + // ParametersSize is the serialized size, in bytes, of the Parameters structure. + ParametersSize = 94 +) + +// Parameters describes level-global properties. +// It also contains information on the auto-maps, which, as per original code, should have been in the game state. +type Parameters struct { + Size int16 + _ [2]byte + + CeilingHazardLevel byte + FloorHazardLevel byte + FloorHazardIsGravity byte + BiohazardRegister byte + RadiationRegister byte + + _ [4]byte + _ [3][27]byte +} + +// DefaultParameters returns a new instance of parameters. +func DefaultParameters() Parameters { + return Parameters{ + Size: ParametersSize, + } +} diff --git a/modding/hacked/ss1/content/archive/level/Parameters_test.go b/modding/hacked/ss1/content/archive/level/Parameters_test.go new file mode 100644 index 0000000..cf3554a --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Parameters_test.go @@ -0,0 +1,28 @@ +package level_test + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultParametersSerialized(t *testing.T) { + buf := bytes.NewBuffer(nil) + param := level.DefaultParameters() + _ = binary.Write(buf, binary.LittleEndian, ¶m) + + data := buf.Bytes() + assert.Equal(t, level.ParametersSize, len(data), "length mismatch") + assert.Equal(t, []byte{ + 0x5e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }, data, "data mismatch") +} diff --git a/modding/hacked/ss1/content/archive/level/RotationUnit.go b/modding/hacked/ss1/content/archive/level/RotationUnit.go new file mode 100644 index 0000000..b270af0 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/RotationUnit.go @@ -0,0 +1,9 @@ +package level + +// RotationUnit describes a rotation in the range 0..255. +type RotationUnit byte + +// ToDegrees returns the value converted to degrees [0..360). +func (unit RotationUnit) ToDegrees() float64 { + return (float64(unit) * 360.0) / 256.0 +} diff --git a/modding/hacked/ss1/content/archive/level/SchedulerInfo.go b/modding/hacked/ss1/content/archive/level/SchedulerInfo.go new file mode 100644 index 0000000..2fde233 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/SchedulerInfo.go @@ -0,0 +1,29 @@ +package level + +const ( + schedulerDefaultSize = 64 + schedulerDefaultElementSize = 8 +) + +// SchedulerInfo describes the basic parameters of a level scheduler. +// Its entries are stored in the Schedules table. +type SchedulerInfo struct { + // Size describes the maximum amount of schedule entries in the table. + Size int32 + // ScheduleCount is the current amount of active schedules. + ScheduleCount int32 + // ElementSize is the size of an schedule entry, in bytes. + ElementSize int32 + // GrowFlag indicates whether the schedule table can be resized. + GrowFlag byte + _ [4]byte + _ [4]byte +} + +// DefaultSchedulerInfo returns a new scheduler info structure with default values. +func DefaultSchedulerInfo() SchedulerInfo { + return SchedulerInfo{ + Size: schedulerDefaultSize, + ElementSize: schedulerDefaultElementSize, + } +} diff --git a/modding/hacked/ss1/content/archive/level/Surveillance.go b/modding/hacked/ss1/content/archive/level/Surveillance.go new file mode 100644 index 0000000..5f5b98a --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Surveillance.go @@ -0,0 +1,6 @@ +package level + +const ( + // SurveillanceObjectCount describes how many surveillance objects a level can have. + SurveillanceObjectCount = 8 +) diff --git a/modding/hacked/ss1/content/archive/level/TextureAnimationEntry.go b/modding/hacked/ss1/content/archive/level/TextureAnimationEntry.go new file mode 100644 index 0000000..60be867 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TextureAnimationEntry.go @@ -0,0 +1,55 @@ +package level + +import "fmt" + +const ( + // TextureAnimationCount describes how many entries of texture animations a level can have. + TextureAnimationCount = 4 + + // TextureAnimationEntrySize is the size, in bytes, of one animation entry. + TextureAnimationEntrySize = 7 +) + +// TextureAnimationEntry describes one entry of the texture animation table. +type TextureAnimationEntry struct { + FrameTime uint16 + CurrentFrameTime uint16 + CurrentFrameIndex byte + FrameCount byte + LoopType TextureAnimationLoopType +} + +// TextureAnimationLoopType describes how a texture animation loop should advance. +type TextureAnimationLoopType byte + +// String returns the textual representation. +func (loopType TextureAnimationLoopType) String() string { + switch loopType { + case TextureAnimationForward: + return "Forward" + case TextureAnimationForthAndBack: + return "Forth-And-Back" + case TextureAnimationBackAndForth: + return "Back-And-Forth" + default: + return fmt.Sprintf("Unknown%02X", int(loopType)) + } +} + +const ( + // TextureAnimationForward has the texture animation run in a linear loop. + TextureAnimationForward = TextureAnimationLoopType(0x00) + // TextureAnimationForthAndBack reverses the sequence at every end. + TextureAnimationForthAndBack = TextureAnimationLoopType(0x01) + // TextureAnimationBackAndForth reverses the sequence at every end. + TextureAnimationBackAndForth = TextureAnimationLoopType(0x81) +) + +// TextureAnimationLoopTypes returns all constants. +func TextureAnimationLoopTypes() []TextureAnimationLoopType { + return []TextureAnimationLoopType{ + TextureAnimationForward, + TextureAnimationForthAndBack, + TextureAnimationBackAndForth, + } +} diff --git a/modding/hacked/ss1/content/archive/level/TextureIndex.go b/modding/hacked/ss1/content/archive/level/TextureIndex.go new file mode 100644 index 0000000..5bb585d --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TextureIndex.go @@ -0,0 +1,17 @@ +package level + +const ( + // DefaultTextureAtlasSize is the amount of textures a level can hold. + DefaultTextureAtlasSize = 54 + // FloorCeilingTextureLimit describes the amount of textures available for floors/ceilings. + FloorCeilingTextureLimit = 32 +) + +// TextureIndex identifies one game texture. +type TextureIndex int16 + +// AtlasIndex is an index into the TextureAtlas. +type AtlasIndex byte + +// TextureAtlas is a selection of textures for the level. +type TextureAtlas []TextureIndex diff --git a/modding/hacked/ss1/content/archive/level/TileFlag.go b/modding/hacked/ss1/content/archive/level/TileFlag.go new file mode 100644 index 0000000..ab82868 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileFlag.go @@ -0,0 +1,227 @@ +package level + +import "fmt" + +// TileFlag describes simple properties of a map tile. +type TileFlag uint32 + +// RealWorldFlag describes simple properties of a map tile in the real world. +type RealWorldFlag TileFlag + +// CyberspaceFlag describes simple properties of a map tile in cyberspace. +type CyberspaceFlag TileFlag + +// ForRealWorld interprets the flag value for the real world. +func (flag TileFlag) ForRealWorld() RealWorldFlag { + return RealWorldFlag(flag) +} + +// ForCyberspace interprets the flag value for cyberspace. +func (flag TileFlag) ForCyberspace() CyberspaceFlag { + return CyberspaceFlag(flag) +} + +// MusicZone returns the music zone. +func (flag TileFlag) MusicZone() TileMusicZone { + return TileMusicZone((flag & 0x0000E000) >> 13) +} + +// WithMusicZone returns a new flag value with the given music index set. +func (flag TileFlag) WithMusicZone(value TileMusicZone) TileFlag { + return TileFlag(uint32(flag&^0x0000E000) | (uint32(value) << 13)) +} + +// Peril returns whether the tile is marked as peril (may play peril music). +func (flag TileFlag) Peril() bool { + return (flag & 0x00001000) != 0 +} + +// WithPeril returns a flag with the given peril set. +func (flag TileFlag) WithPeril(value bool) TileFlag { + var valueFlag uint32 + if value { + valueFlag = 0x00001000 + } + return TileFlag(uint32(flag&^0x00001000) | valueFlag) +} + +// SlopeControl returns the slope control as per flags. +func (flag TileFlag) SlopeControl() TileSlopeControl { + return TileSlopeControl((flag & 0x00000C00) >> 10) +} + +// WithSlopeControl returns a new flag value with the given slope control set. +func (flag TileFlag) WithSlopeControl(ctrl TileSlopeControl) TileFlag { + return TileFlag(uint32(flag&^0x00000C00) | (uint32(ctrl&0x3) << 10)) +} + +// AsTileFlag returns the flags as regular tile flag value. +func (flag RealWorldFlag) AsTileFlag() TileFlag { + return TileFlag(flag) +} + +// WallTextureOffset returns the vertical offset (in tile height units) to apply for wall textures. +func (flag RealWorldFlag) WallTextureOffset() TileHeightUnit { + return TileHeightUnit(flag & 0x0000001F) +} + +// WithWallTextureOffset returns a flag value with the given wall texture offset. +func (flag RealWorldFlag) WithWallTextureOffset(value TileHeightUnit) RealWorldFlag { + return RealWorldFlag(uint32(flag&^0x0000001F) | uint32(value&0x1F)) +} + +// WallTexturePattern returns the pattern to apply for walls. +func (flag RealWorldFlag) WallTexturePattern() WallTexturePattern { + return WallTexturePattern(byte(flag&0x00000060) >> 5) +} + +// WithWallTexturePattern returns a flag with the given pattern set. +func (flag RealWorldFlag) WithWallTexturePattern(value WallTexturePattern) RealWorldFlag { + return RealWorldFlag(uint32(flag&^0x00000060) | (uint32(value&0x3) << 5)) +} + +// UseAdjacentWallTexture returns whether the wall texture from the adjacent tile should be used for each side. +func (flag RealWorldFlag) UseAdjacentWallTexture() bool { + return (flag & 0x00000100) != 0 +} + +// WithUseAdjacentWallTexture returns a flag with the given usage set. +func (flag RealWorldFlag) WithUseAdjacentWallTexture(value bool) RealWorldFlag { + var valueFlag uint32 + if value { + valueFlag = 0x00000100 + } + return RealWorldFlag(uint32(flag&^0x00000100) | valueFlag) +} + +// Deconstructed returns whether the tile is marked as heavily deconstructed (should play spooky music). +func (flag RealWorldFlag) Deconstructed() bool { + return (flag & 0x00000200) != 0 +} + +// WithDeconstructed returns a flag with the given deconstruction set. +func (flag RealWorldFlag) WithDeconstructed(value bool) RealWorldFlag { + var valueFlag uint32 + if value { + valueFlag = 0x00000200 + } + return RealWorldFlag(uint32(flag&^0x00000200) | valueFlag) +} + +// FloorShadow returns the floor shadow value. Range: [0..GradesOfShadow]. +func (flag RealWorldFlag) FloorShadow() int { + return int((flag & 0x000F0000) >> 16) +} + +// WithFloorShadow returns a new flag value with the given floor shadow set. Values beyond allowed range are ignored. +func (flag RealWorldFlag) WithFloorShadow(value int) RealWorldFlag { + if (value < 0) || (value >= GradesOfShadow) { + return flag + } + return RealWorldFlag(uint32(flag&^0x000F0000) | (uint32(value) << 16)) +} + +// CeilingShadow returns the ceiling shadow value. Range: [0..GradesOfShadow]. +func (flag RealWorldFlag) CeilingShadow() int { + return int((flag & 0x0F000000) >> 24) +} + +// WithCeilingShadow returns a new flag value with the given ceiling shadow set. Values beyond allowed range are ignored. +func (flag RealWorldFlag) WithCeilingShadow(value int) RealWorldFlag { + if (value < 0) || (value >= GradesOfShadow) { + return flag + } + return RealWorldFlag(uint32(flag&^0x0F000000) | (uint32(value) << 24)) +} + +// TileVisited returns whether the tile is marked as being visited (seen). +func (flag RealWorldFlag) TileVisited() bool { + return (flag & 0x80000000) != 0 +} + +// WithTileVisited returns a flag with the given visited set. +func (flag RealWorldFlag) WithTileVisited(value bool) RealWorldFlag { + var valueFlag uint32 + if value { + valueFlag = 0x80000000 + } + return RealWorldFlag(uint32(flag&^0x80000000) | valueFlag) +} + +// AsTileFlag returns the flags as regular tile flag value. +func (flag CyberspaceFlag) AsTileFlag() TileFlag { + return TileFlag(flag) +} + +// GameOfLifeState returns the current game-of-life state. +func (flag CyberspaceFlag) GameOfLifeState() int { + return int(byte(flag&0x00000060) >> 5) +} + +// WithGameOfLifeState returns a flag with the given game-of-life state set. +func (flag CyberspaceFlag) WithGameOfLifeState(value int) CyberspaceFlag { + return CyberspaceFlag(uint32(flag&^0x00000060) | (uint32(value&0x3) << 5)) +} + +// FlightPull returns the pull applying in a tile. +func (flag CyberspaceFlag) FlightPull() CyberspaceFlightPull { + return CyberspaceFlightPull((uint32(flag&0x01000000) >> 20) | (uint32(flag&0x000F0000) >> 16)) +} + +// WithFlightPull returns a flag instance with the given pull applied. +func (flag CyberspaceFlag) WithFlightPull(value CyberspaceFlightPull) CyberspaceFlag { + newFlag := uint32(flag &^ 0x010F0000) + newFlag |= uint32(value&0x0F) << 16 + newFlag |= uint32(value&0x10) << 20 + return CyberspaceFlag(newFlag) +} + +type TileMusicZone byte + +const ( + TileMusicZoneNoMusic TileMusicZone = 0 + TileMusicZoneHospital TileMusicZone = 1 + TileMusicZoneExecutive TileMusicZone = 2 + TileMusicZoneIndustrial TileMusicZone = 3 + TileMusicZoneMetal TileMusicZone = 4 + TileMusicZonePark TileMusicZone = 5 + TileMusicZoneBridge TileMusicZone = 6 + TileMusicZoneElevator TileMusicZone = 7 +) + +// TileSlopeControls returns all control values. +func TileMusicZones() []TileMusicZone { + return []TileMusicZone{ + TileMusicZoneNoMusic, + TileMusicZoneHospital, + TileMusicZoneExecutive, + TileMusicZoneIndustrial, + TileMusicZoneMetal, + TileMusicZonePark, + TileMusicZoneBridge, + TileMusicZoneElevator, + } +} + +func (ctrl TileMusicZone) String() string { + switch ctrl { + case TileMusicZoneNoMusic: + return "No Music" + case TileMusicZoneHospital: + return "Hospital" + case TileMusicZoneExecutive: + return "Executive" + case TileMusicZoneIndustrial: + return "Industrial" + case TileMusicZoneMetal: + return "Metal" + case TileMusicZonePark: + return "Park" + case TileMusicZoneBridge: + return "Bridge" + case TileMusicZoneElevator: + return "Elevator" + default: + return fmt.Sprintf("Unknown%02X", int(ctrl)) + } +} diff --git a/modding/hacked/ss1/content/archive/level/TileHeightUnit.go b/modding/hacked/ss1/content/archive/level/TileHeightUnit.go new file mode 100644 index 0000000..5fb99ff --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileHeightUnit.go @@ -0,0 +1,10 @@ +package level + +// TileHeightUnit constants are listed below. +const ( + TileHeightUnitMin TileHeightUnit = 0 + TileHeightUnitMax TileHeightUnit = 32 +) + +// TileHeightUnit describes a height in a range of 0..32. +type TileHeightUnit byte diff --git a/modding/hacked/ss1/content/archive/level/TileMap.go b/modding/hacked/ss1/content/archive/level/TileMap.go new file mode 100644 index 0000000..0ac78a4 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileMap.go @@ -0,0 +1,93 @@ +package level + +import "github.com/inkyblackness/hacked/ss1/serial" + +// TileMapEntry describes one tile of the map. +type TileMapEntry struct { + // Type indicates what kind of tile this is. + Type TileType + // Floor describes floor properties. + Floor FloorInfo + // Ceiling describes ceiling properties. + Ceiling CeilingInfo + // SlopeHeight indicates for non-flat tiles the height offset. + SlopeHeight TileHeightUnit + + // FirstObjectIndex points into the level object cross reference table to the first object in this tile. + FirstObjectIndex int16 + // TextureInfo describes tile texturing. + TextureInfo TileTextureInfo + // Flags contains arbitrary additional information. + Flags TileFlag + + // SubClip is always 0xFF. + SubClip byte + _ [2]byte + // LightDelta describes the light offsets of this tile. + LightDelta LightDeltaInfo +} + +// Reset puts the tile into an initial state. +func (tile *TileMapEntry) Reset() { + *tile = TileMapEntry{} + tile.SubClip = 0xFF +} + +// FloorTileHeightAt returns the height of the floor (in tile unit) at the given fine position. +func (tile TileMapEntry) FloorTileHeightAt(pos FinePosition, height HeightShift) float32 { + floorHeight, _ := height.ValueFromTileHeight(tile.Floor.AbsoluteHeight()) + if tile.Flags.SlopeControl() == TileSlopeControlFloorFlat { + return floorHeight + } + slopeHeight, _ := height.ValueFromTileHeight(tile.SlopeHeight) + fineHeight := slopeHeight * pos.SlopeFactorFor(tile.Type) + return floorHeight + fineHeight +} + +// CeilingTileHeightAt returns the height of the ceiling (in tile unit) at the given fine position. +func (tile TileMapEntry) CeilingTileHeightAt(pos FinePosition, height HeightShift) float32 { + ceilingHeight, _ := height.ValueFromTileHeight(tile.Ceiling.AbsoluteHeight()) + slopeControl := tile.Flags.SlopeControl() + if slopeControl == TileSlopeControlCeilingFlat { + return ceilingHeight + } + slopeHeight, _ := height.ValueFromTileHeight(tile.SlopeHeight) + var slopeFactor float32 + if slopeControl == TileSlopeControlCeilingMirrored { + slopeFactor = pos.SlopeFactorFor(tile.Type) + } else { + slopeFactor = pos.SlopeFactorFor(tile.Type.Info().SlopeInvertedType) + } + fineHeight := slopeHeight * slopeFactor + return ceilingHeight - fineHeight +} + +// TileMap is a rectangular set of tiles. +// The first index is the Y-axis, the second index the X-axis. This way the map can be serialized quicker. +type TileMap struct { + entries []TileMapEntry +} + +// NewTileMap returns a new, initialized map. +func NewTileMap(width, height int) TileMap { + entries := make([]TileMapEntry, width*height) + for i := 0; i < len(entries); i++ { + entries[i].Reset() + } + return TileMap{entries: entries} +} + +// Tile returns a pointer to the tile within the map for given position. +// Nil is returned for a coordinate outside the boundaries. +func (m TileMap) Tile(pos TilePosition, xShift int) *TileMapEntry { + index := int(pos.X) + (int(pos.Y) << xShift) + if (int(pos.X) >= (1 << xShift)) || (index >= len(m.entries)) { + return nil + } + return &m.entries[index] +} + +// Code serializes the map. +func (m TileMap) Code(coder serial.Coder) { + coder.Code(m.entries) +} diff --git a/modding/hacked/ss1/content/archive/level/TileMap_test.go b/modding/hacked/ss1/content/archive/level/TileMap_test.go new file mode 100644 index 0000000..f84d944 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileMap_test.go @@ -0,0 +1,16 @@ +package level_test + +import ( + "encoding/binary" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + + "github.com/stretchr/testify/assert" +) + +func TestTileMapEntrySerializedSize(t *testing.T) { + var entry level.TileMapEntry + size := binary.Size(&entry) + assert.Equal(t, 16, size, "Size mismatch") +} diff --git a/modding/hacked/ss1/content/archive/level/TilePosition.go b/modding/hacked/ss1/content/archive/level/TilePosition.go new file mode 100644 index 0000000..980ddcf --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TilePosition.go @@ -0,0 +1,62 @@ +package level + +// TilePosition contains the coordinates of a tile in a level. +type TilePosition struct { + X byte + Y byte +} + +// FinePosition contains the coordinates of an object within a tile. +type FinePosition struct { + X byte + Y byte +} + +// SlopeFactorFor returns the factor of how much the slope of given tile type would apply at the fine position. +func (pos FinePosition) SlopeFactorFor(tileType TileType) float32 { + southToNorth := float32(pos.Y) / 255 + westToEast := float32(pos.X) / 255 + swneDiag := func(northWest, southEast float32) float32 { + if pos.X < pos.Y { + return northWest + } + return southEast + } + nwseDiag := func(southWest, northEast float32) float32 { + if (255 - pos.X) < pos.Y { + return northEast + } + return southWest + } + + switch tileType { + case TileTypeSlopeSouthToNorth: + return southToNorth + case TileTypeSlopeWestToEast: + return westToEast + case TileTypeSlopeNorthToSouth: + return 1 - southToNorth + case TileTypeSlopeEastToWest: + return 1 - westToEast + + case TileTypeValleySouthEastToNorthWest: + return nwseDiag(1-westToEast, southToNorth) + case TileTypeValleySouthWestToNorthEast: + return swneDiag(southToNorth, westToEast) + case TileTypeValleyNorthWestToSouthEast: + return nwseDiag(1-southToNorth, westToEast) + case TileTypeValleyNorthEastToSouthWest: + return swneDiag(1-westToEast, 1-southToNorth) + + case TileTypeRidgeNorthWestToSouthEast: + return nwseDiag(westToEast, 1-southToNorth) + case TileTypeRidgeNorthEastToSouthWest: + return swneDiag(1-southToNorth, 1-westToEast) + case TileTypeRidgeSouthEastToNorthWest: + return nwseDiag(southToNorth, 1-westToEast) + case TileTypeRidgeSouthWestToNorthEast: + return swneDiag(westToEast, southToNorth) + } + + return 0.0 +} diff --git a/modding/hacked/ss1/content/archive/level/TilePosition_test.go b/modding/hacked/ss1/content/archive/level/TilePosition_test.go new file mode 100644 index 0000000..2898b9e --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TilePosition_test.go @@ -0,0 +1,28 @@ +package level_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" +) + +func TestFinePositionSlopeFactorFor(t *testing.T) { + tt := []struct { + pos level.FinePosition + tileType level.TileType + expected float32 + }{ + {level.FinePosition{X: 127, Y: 127}, level.TileTypeOpen, 0.0}, + {level.FinePosition{X: 127, Y: 127}, level.TileTypeSlopeNorthToSouth, 0.5}, + {level.FinePosition{X: 127, Y: 127}, level.TileTypeRidgeNorthWestToSouthEast, 0.5}, + {level.FinePosition{X: 0, Y: 127}, level.TileTypeRidgeNorthWestToSouthEast, 0.0}, + {level.FinePosition{X: 127, Y: 0}, level.TileTypeRidgeNorthWestToSouthEast, 0.5}, + } + + for _, tc := range tt { + result := tc.pos.SlopeFactorFor(tc.tileType) + assert.InDeltaf(t, tc.expected, result, 0.01, "Type: %v, Pos: %v", tc.tileType, tc.pos) + } +} diff --git a/modding/hacked/ss1/content/archive/level/TileSlopeControl.go b/modding/hacked/ss1/content/archive/level/TileSlopeControl.go new file mode 100644 index 0000000..66cc551 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileSlopeControl.go @@ -0,0 +1,59 @@ +package level + +import "fmt" + +// TileSlopeControl defines how the floor and ceiling of a sloped tile should be processed. +type TileSlopeControl byte + +// String returns the textual representation of the value. +func (ctrl TileSlopeControl) String() string { + switch ctrl { + case TileSlopeControlCeilingInverted: + return "CeilingInverted" + case TileSlopeControlCeilingMirrored: + return "CeilingMirrored" + case TileSlopeControlCeilingFlat: + return "CeilingFlat" + case TileSlopeControlFloorFlat: + return "FloorFlat" + default: + return fmt.Sprintf("Unknown%02X", int(ctrl)) + } +} + +// FloorSlopeFactors returns the slope factors for the given tile type as per control constant. +func (ctrl TileSlopeControl) FloorSlopeFactors(tileType TileType) SlopeFactors { + if ctrl == TileSlopeControlFloorFlat { + return SlopeFactors{} + } + return tileType.Info().SlopeFloorFactors +} + +// CeilingSlopeFactors returns the slope factors for the given tile type as per control constant. +func (ctrl TileSlopeControl) CeilingSlopeFactors(tileType TileType) SlopeFactors { + if ctrl == TileSlopeControlCeilingFlat { + return SlopeFactors{} + } + if ctrl == TileSlopeControlCeilingMirrored { + return tileType.Info().SlopeFloorFactors + } + return tileType.Info().SlopeInvertedType.Info().SlopeFloorFactors +} + +// TileSlopeControl constants. +const ( + TileSlopeControlCeilingInverted TileSlopeControl = 0 + TileSlopeControlCeilingMirrored TileSlopeControl = 1 + TileSlopeControlCeilingFlat TileSlopeControl = 2 + TileSlopeControlFloorFlat TileSlopeControl = 3 +) + +// TileSlopeControls returns all control values. +func TileSlopeControls() []TileSlopeControl { + return []TileSlopeControl{ + TileSlopeControlCeilingInverted, + TileSlopeControlCeilingMirrored, + TileSlopeControlCeilingFlat, + TileSlopeControlFloorFlat, + } +} diff --git a/modding/hacked/ss1/content/archive/level/TileTextureInfo.go b/modding/hacked/ss1/content/archive/level/TileTextureInfo.go new file mode 100644 index 0000000..b6bcd13 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileTextureInfo.go @@ -0,0 +1,72 @@ +package level + +// TileTextureInfo describes the textures used for a map tile. +type TileTextureInfo uint16 + +// WallTextureIndex returns the texture index into the texture atlas for the walls. +// Valid range [0..63]. +// This property is only valid in real world. +func (info TileTextureInfo) WallTextureIndex() AtlasIndex { + return AtlasIndex(info & 0x003F) +} + +// WithWallTextureIndex returns an info with given index set. +// Values outside valid range are ignored. +func (info TileTextureInfo) WithWallTextureIndex(value AtlasIndex) TileTextureInfo { + if value >= 64 { + return info + } + return TileTextureInfo(uint16(info&^0x003F) | uint16(value&0x003F)) +} + +// CeilingTextureIndex returns the texture index into the texture atlas for the ceiling. +// Valid range [0..31]. +// This property is only valid in real world. +func (info TileTextureInfo) CeilingTextureIndex() AtlasIndex { + return AtlasIndex((info & 0x07C0) >> 6) +} + +// WithCeilingTextureIndex returns an info with given index set. +// Values outside valid range are ignored. +func (info TileTextureInfo) WithCeilingTextureIndex(value AtlasIndex) TileTextureInfo { + if value >= FloorCeilingTextureLimit { + return info + } + return TileTextureInfo(uint16(info&^0x07C0) | (uint16(value&0x001F) << 6)) +} + +// FloorTextureIndex returns the texture index into the texture atlas for the floor. +// Valid range [0..31]. +// This property is only valid in real world. +func (info TileTextureInfo) FloorTextureIndex() AtlasIndex { + return AtlasIndex((info & 0xF800) >> 11) +} + +// WithFloorTextureIndex returns an info with given index set. +// Values outside valid range are ignored. +func (info TileTextureInfo) WithFloorTextureIndex(value AtlasIndex) TileTextureInfo { + if value >= FloorCeilingTextureLimit { + return info + } + return TileTextureInfo(uint16(info&^0xF800) | (uint16(value&0x001F) << 11)) +} + +// FloorPaletteIndex returns the palette index for the floor in cyberspace. +func (info TileTextureInfo) FloorPaletteIndex() byte { + return byte(info) +} + +// WithFloorPaletteIndex returns an info with given index set. +func (info TileTextureInfo) WithFloorPaletteIndex(value byte) TileTextureInfo { + return TileTextureInfo(uint16(info&0xFF00) | uint16(value)) +} + +// CeilingPaletteIndex returns the palette index for the ceiling in cyberspace. +func (info TileTextureInfo) CeilingPaletteIndex() byte { + return byte(info >> 8) +} + +// WithCeilingPaletteIndex returns an info with given index set. +func (info TileTextureInfo) WithCeilingPaletteIndex(value byte) TileTextureInfo { + return TileTextureInfo(uint16(info&0x00FF) | (uint16(value) << 8)) +} diff --git a/modding/hacked/ss1/content/archive/level/TileType.go b/modding/hacked/ss1/content/archive/level/TileType.go new file mode 100644 index 0000000..d54b1e4 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileType.go @@ -0,0 +1,66 @@ +package level + +import "fmt" + +// TileType describes the general type of a map tile. +type TileType byte + +// Info returns the information associated with the tile type. +func (t TileType) Info() TileTypeInfo { + if int(t) < len(tileTypeInfoList) { + return tileTypeInfoList[t] + } + info := tileTypeInfoList[TileTypeSolid] + info.Name = t.String() + info.SlopeInvertedType = t + return info +} + +// String returns the textual representation of the type. +func (t TileType) String() string { + if int(t) < len(tileTypeInfoList) { + return tileTypeInfoList[t].Name + } + return fmt.Sprintf("Unknown%02X", int(t)) +} + +// Tiles come in different forms: +// Solid tiles can not be entered, Open tiles are regular tiles with a flat floor and a flat ceiling. +// DiagonalOpen tiles are those with flat floors and ceilings, and two walls cut off by one diagonal wall. +// Slope tiles have a sloped floor (or ceiling). Valley tiles have one floor vertex lower while Ridge tiles have one +// floor vertex higher than the other three. +const ( + TileTypeSolid TileType = 0x00 + TileTypeOpen TileType = 0x01 + + TileTypeDiagonalOpenSouthEast TileType = 0x02 + TileTypeDiagonalOpenSouthWest TileType = 0x03 + TileTypeDiagonalOpenNorthWest TileType = 0x04 + TileTypeDiagonalOpenNorthEast TileType = 0x05 + + TileTypeSlopeSouthToNorth TileType = 0x06 + TileTypeSlopeWestToEast TileType = 0x07 + TileTypeSlopeNorthToSouth TileType = 0x08 + TileTypeSlopeEastToWest TileType = 0x09 + + TileTypeValleySouthEastToNorthWest TileType = 0x0A + TileTypeValleySouthWestToNorthEast TileType = 0x0B + TileTypeValleyNorthWestToSouthEast TileType = 0x0C + TileTypeValleyNorthEastToSouthWest TileType = 0x0D + + TileTypeRidgeNorthWestToSouthEast TileType = 0x0E + TileTypeRidgeNorthEastToSouthWest TileType = 0x0F + TileTypeRidgeSouthEastToNorthWest TileType = 0x10 + TileTypeRidgeSouthWestToNorthEast TileType = 0x11 +) + +// TileTypes returns a list of all known tile types. +func TileTypes() []TileType { + return []TileType{ + TileTypeSolid, TileTypeOpen, + TileTypeDiagonalOpenSouthEast, TileTypeDiagonalOpenSouthWest, TileTypeDiagonalOpenNorthWest, TileTypeDiagonalOpenNorthEast, + TileTypeSlopeSouthToNorth, TileTypeSlopeWestToEast, TileTypeSlopeNorthToSouth, TileTypeSlopeEastToWest, + TileTypeValleySouthEastToNorthWest, TileTypeValleySouthWestToNorthEast, TileTypeValleyNorthWestToSouthEast, TileTypeValleyNorthEastToSouthWest, + TileTypeRidgeNorthWestToSouthEast, TileTypeRidgeNorthEastToSouthWest, TileTypeRidgeSouthEastToNorthWest, TileTypeRidgeSouthWestToNorthEast, + } +} diff --git a/modding/hacked/ss1/content/archive/level/TileTypeInfo.go b/modding/hacked/ss1/content/archive/level/TileTypeInfo.go new file mode 100644 index 0000000..6a2e6cd --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileTypeInfo.go @@ -0,0 +1,53 @@ +package level + +// SlopeFactors is a list of multipliers for each direction of a tile. +type SlopeFactors [8]float32 + +// Negated returns the factors multiplied by -1. +func (factors SlopeFactors) Negated() SlopeFactors { + return SlopeFactors{ + factors[0] * -1, factors[1] * -1, factors[2] * -1, factors[3] * -1, + factors[4] * -1, factors[5] * -1, factors[6] * -1, factors[7] * -1, + } +} + +// TileTypeInfo is the meta information about a tile type. +type TileTypeInfo struct { + // Name is the textual representation of the tile type. + Name string + + // SolidSides is a bitfield of cardinal directions describing solid walls. + SolidSides DirectionMask + + // SlopeFloorFactors defines how a slope affects the floor in each direction of a tile [0.0 .. 1.0]. + SlopeFloorFactors SlopeFactors + + // SlopeInvertedType is the type that inverts the slope to form a solid tile if merged (e.g. floor & ceiling). + // Types that have no slope invert themselves. + SlopeInvertedType TileType +} + +var tileTypeInfoList = []TileTypeInfo{ + {"Solid", DirNorth.Plus(DirEast).Plus(DirSouth).Plus(DirWest), SlopeFactors{0, 0, 0, 0, 0, 0, 0, 0}, TileTypeSolid}, + {"Open", 0, SlopeFactors{0, 0, 0, 0, 0, 0, 0, 0}, TileTypeOpen}, + + {"DiagonalOpenSouthEast", DirNorth.Plus(DirWest), SlopeFactors{0, 0, 0, 0, 0, 0, 0, 0}, TileTypeDiagonalOpenSouthEast}, + {"DiagonalOpenSouthWest", DirNorth.Plus(DirEast), SlopeFactors{0, 0, 0, 0, 0, 0, 0, 0}, TileTypeDiagonalOpenSouthWest}, + {"DiagonalOpenNorthWest", DirSouth.Plus(DirEast), SlopeFactors{0, 0, 0, 0, 0, 0, 0, 0}, TileTypeDiagonalOpenNorthWest}, + {"DiagonalOpenNorthEast", DirSouth.Plus(DirWest), SlopeFactors{0, 0, 0, 0, 0, 0, 0, 0}, TileTypeDiagonalOpenNorthEast}, + + {"SlopeSouthToNorth", 0, SlopeFactors{1.0, 1.0, 0.5, 0.0, 0.0, 0.0, 0.5, 1.0}, TileTypeSlopeNorthToSouth}, + {"SlopeWestToEast", 0, SlopeFactors{0.5, 1.0, 1.0, 1.0, 0.5, 0.0, 0.0, 0.0}, TileTypeSlopeEastToWest}, + {"SlopeNorthToSouth", 0, SlopeFactors{0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 0.5, 0.0}, TileTypeSlopeSouthToNorth}, + {"SlopeEastToWest", 0, SlopeFactors{0.5, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0}, TileTypeSlopeWestToEast}, + + {"ValleySouthEastToNorthWest", 0, SlopeFactors{1.0, 1.0, 0.5, 0.0, 0.5, 1.0, 1.0, 1.0}, TileTypeRidgeNorthWestToSouthEast}, + {"ValleySouthWestToNorthEast", 0, SlopeFactors{1.0, 1.0, 1.0, 1.0, 0.5, 0.0, 0.5, 1.0}, TileTypeRidgeNorthEastToSouthWest}, + {"ValleyNorthWestToSouthEast", 0, SlopeFactors{0.5, 1.0, 1.0, 1.0, 1.0, 1.0, 0.5, 0.0}, TileTypeRidgeSouthEastToNorthWest}, + {"ValleyNorthEastToSouthWest", 0, SlopeFactors{0.5, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0, 1.0}, TileTypeRidgeSouthWestToNorthEast}, + + {"RidgeNorthWestToSouthEast", 0, SlopeFactors{0.0, 0.0, 0.5, 1.0, 0.5, 0.0, 0.0, 0.0}, TileTypeValleySouthEastToNorthWest}, + {"RidgeNorthEastToSouthWest", 0, SlopeFactors{0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 0.5, 0.0}, TileTypeValleySouthWestToNorthEast}, + {"RidgeSouthEastToNorthWest", 0, SlopeFactors{0.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5, 1.0}, TileTypeValleyNorthWestToSouthEast}, + {"RidgeSouthWestToNorthEast", 0, SlopeFactors{0.5, 1.0, 0.5, 0.0, 0.0, 0.0, 0.0, 0.0}, TileTypeValleyNorthEastToSouthWest}, +} diff --git a/modding/hacked/ss1/content/archive/level/TileType_test.go b/modding/hacked/ss1/content/archive/level/TileType_test.go new file mode 100644 index 0000000..7405e15 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/TileType_test.go @@ -0,0 +1,49 @@ +package level_test + +import ( + "fmt" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + + "github.com/stretchr/testify/assert" +) + +func TestTileTypeInfoSlopeFactorsSealTileWithInversion(t *testing.T) { + tt := []level.TileType{ + level.TileTypeSlopeSouthToNorth, + level.TileTypeSlopeWestToEast, + level.TileTypeSlopeNorthToSouth, + level.TileTypeSlopeEastToWest, + + level.TileTypeValleySouthEastToNorthWest, + level.TileTypeValleySouthWestToNorthEast, + level.TileTypeValleyNorthWestToSouthEast, + level.TileTypeValleyNorthEastToSouthWest, + + level.TileTypeRidgeNorthWestToSouthEast, + level.TileTypeRidgeNorthEastToSouthWest, + level.TileTypeRidgeSouthEastToNorthWest, + level.TileTypeRidgeSouthWestToNorthEast, + } + for _, tc := range tt { + info := tc.Info() + mirroredInfo := info.SlopeInvertedType.Info() + var factors level.SlopeFactors + for i := 0; i < 8; i++ { + factors[i] = info.SlopeFloorFactors[i] + mirroredInfo.SlopeFloorFactors[i] + } + assert.Equal(t, level.SlopeFactors{1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0}, factors, fmt.Sprintf("Factors don't seal tile for type 0x%02X", tc)) + } +} + +func TestTileTypeInfoDefaultsToSolidEquivalent(t *testing.T) { + tileType := level.TileType(0x77) + expected := level.TileTypeInfo{ + Name: "Unknown77", + SolidSides: level.DirNorth.Plus(level.DirEast).Plus(level.DirSouth).Plus(level.DirWest), + SlopeFloorFactors: level.SlopeFactors{0, 0, 0, 0, 0, 0, 0, 0}, + SlopeInvertedType: tileType, + } + assert.Equal(t, expected, tileType.Info()) +} diff --git a/modding/hacked/ss1/content/archive/level/Unknowns.go b/modding/hacked/ss1/content/archive/level/Unknowns.go new file mode 100644 index 0000000..ce76888 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/Unknowns.go @@ -0,0 +1,16 @@ +package level + +// UnknownL41 is the unknown data of L41. +type UnknownL41 [1]byte + +// UnknownL48 is the unknown data of L48. +type UnknownL48 [0x30]byte + +// UnknownL49 is the unknown data of L49. +type UnknownL49 [0x1C0]byte + +// UnknownL50 is the unknown data of L50. +type UnknownL50 [2]byte + +// UnknownL52 is the unknown data of L52. +type UnknownL52 [2]byte diff --git a/modding/hacked/ss1/content/archive/level/WallHeights.go b/modding/hacked/ss1/content/archive/level/WallHeights.go new file mode 100644 index 0000000..1b86c6a --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/WallHeights.go @@ -0,0 +1,119 @@ +package level + +// WallHeights describes the height delta, in HeightUnits, one would take to +// cross to the other tile. A value of 0 means equal floor, a negative value a fall down. +// A value of 0x20 (= maximum of height units) means a solid wall. +// +// For each cardinal direction, facing to the specified direction, +// the first entry is the left side, the second the center, and the third the right side. +type WallHeights struct { + North [3]float32 + East [3]float32 + South [3]float32 + West [3]float32 +} + +// Reset sets all the heights to zero. +func (heights *WallHeights) Reset() { + *heights = WallHeights{} +} + +// WallHeightsMap is rectangular table of wall heights, typically mirroring a tile map. +type WallHeightsMap [][]WallHeights + +// NewWallHeightsMap returns a new, initialized map. +func NewWallHeightsMap(width, height int) WallHeightsMap { + m := make([][]WallHeights, height) + for y := 0; y < height; y++ { + m[y] = make([]WallHeights, width) + for x := 0; x < width; x++ { + m[y][x].Reset() + } + } + return m +} + +// Tile returns a pointer to the tile within the map for given position. +func (m WallHeightsMap) Tile(pos TilePosition) *WallHeights { + return &m[pos.Y][pos.X] +} + +// CalculateFrom updates all the wall heights according to the specified map. +func (m *WallHeightsMap) CalculateFrom(tileMap interface { + Tile(pos TilePosition) *TileMapEntry +}) { + posAt := func(x, y int) TilePosition { + return TilePosition{X: byte(x), Y: byte(y)} + } + for y, row := range *m { + for x := 0; x < len(row); x++ { + tile := tileMap.Tile(posAt(x, y)) + if tile == nil { + continue + } + heights := &row[x] + heights.North = calculateWallHeight(tile, DirNorth, tileMap.Tile(posAt(x, y+1)), DirSouth) + heights.East = calculateWallHeight(tile, DirEast, tileMap.Tile(posAt(x+1, y)), DirWest) + heights.South = calculateWallHeight(tile, DirSouth, tileMap.Tile(posAt(x, y-1)), DirNorth) + heights.West = calculateWallHeight(tile, DirWest, tileMap.Tile(posAt(x-1, y)), DirEast) + } + } +} + +func calculateWallHeight(entry *TileMapEntry, entrySide Direction, other *TileMapEntry, otherSide Direction) [3]float32 { + var result [3]float32 + baseHeights := func(tile *TileMapEntry, side Direction) (floor TileHeightUnit, slope TileHeightUnit, ceiling TileHeightUnit) { + floor = TileHeightUnitMax + slope = TileHeightUnitMin + ceiling = TileHeightUnitMax + if (tile != nil) && ((tile.Type.Info().SolidSides & side.AsMask()) == 0) { + floor = tile.Floor.AbsoluteHeight() + slope = tile.SlopeHeight + ceiling = tile.Ceiling.AbsoluteHeight() + } + return + } + + otherFloorHeight, otherSlopeHeight, otherCeilingHeight := baseHeights(other, otherSide) + var otherFloorFactors SlopeFactors + var otherCeilingFactors SlopeFactors + if other != nil { + otherSlopeControl := other.Flags.SlopeControl() + otherFloorFactors = otherSlopeControl.FloorSlopeFactors(other.Type) + otherCeilingFactors = otherSlopeControl.CeilingSlopeFactors(other.Type) + } + + entryFloorHeight, entrySlopeHeight, entryCeilingHeight := baseHeights(entry, entrySide) + entrySlopeControl := entry.Flags.SlopeControl() + entryFloorFactors := entrySlopeControl.FloorSlopeFactors(entry.Type) + entryCeilingFactors := entrySlopeControl.CeilingSlopeFactors(entry.Type) + + sideHeights := func(side Direction, sideOffset int, factors SlopeFactors, slopeHeight TileHeightUnit, absoluteHeight TileHeightUnit) [3]float32 { + return [3]float32{ + factors[side.Offset(-sideOffset)]*float32(slopeHeight) + float32(absoluteHeight), + factors[side.Offset(0)]*float32(slopeHeight) + float32(absoluteHeight), + factors[side.Offset(+sideOffset)]*float32(slopeHeight) + float32(absoluteHeight), + } + } + entryFloorSideHeights := sideHeights(entrySide, 1, entryFloorFactors, entrySlopeHeight, entryFloorHeight) + entryCeilingSideHeights := sideHeights(entrySide, 1, entryCeilingFactors.Negated(), entrySlopeHeight, entryCeilingHeight) + otherFloorSideHeights := sideHeights(otherSide, -1, otherFloorFactors, otherSlopeHeight, otherFloorHeight) + otherCeilingSideHeights := sideHeights(otherSide, -1, otherCeilingFactors.Negated(), otherSlopeHeight, otherCeilingHeight) + + for i := 0; i < 3; i++ { + switch { + case (otherFloorSideHeights[i] == entryFloorSideHeights[i]) && (entryFloorSideHeights[i] == float32(TileHeightUnitMax)): + // shortcut for solid-to-solid - no "wall" to see here + result[i] = 0 + case (otherFloorSideHeights[i] >= otherCeilingSideHeights[i]) || // other ceiling side seals off at its floor + (otherCeilingSideHeights[i] <= entryFloorSideHeights[i]) || // other ceiling side seals off at this floor + (otherFloorSideHeights[i] >= entryCeilingSideHeights[i]) || // other floor side seals off at this ceiling + (entryFloorSideHeights[i] >= entryCeilingSideHeights[i]): // own ceiling side seals off at floor + result[i] = float32(TileHeightUnitMax) + default: + result[i] = otherFloorSideHeights[i] - entryFloorSideHeights[i] + } + } + + return result +} diff --git a/modding/hacked/ss1/content/archive/level/WallHeights_test.go b/modding/hacked/ss1/content/archive/level/WallHeights_test.go new file mode 100644 index 0000000..427dfc2 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/WallHeights_test.go @@ -0,0 +1,208 @@ +package level_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" +) + +type WallHeightsSuite struct { + suite.Suite + + tileMap level.TileMap + heightsMap level.WallHeightsMap + + tile *level.TileMapEntry +} + +func TestWallHeightsSuite(t *testing.T) { + suite.Run(t, new(WallHeightsSuite)) +} + +func (suite *WallHeightsSuite) SetupTest() { + suite.tileMap = level.NewTileMap(4, 4) + suite.heightsMap = level.NewWallHeightsMap(3, 3) + suite.tile = nil +} + +func (suite *WallHeightsSuite) TestCalculateFromResetsToZeroForAllSolids() { + suite.givenARandomHeightsMap() + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightsShouldBeForTile(1, 1, 0.0) +} + +func (suite *WallHeightsSuite) TestCalculateFromSetsToMaximumHeightUnitFromOpenToSolid() { + suite.givenTile(1, 1).typed(level.TileTypeOpen) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightsShouldBeForTile(1, 1, 32.0) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, 32, 32, 32) + suite.thenHeightShouldBeForTileAt(1, 0, level.DirNorth, 32, 32, 32) + suite.thenHeightShouldBeForTileAt(0, 1, level.DirEast, 32, 32, 32) + suite.thenHeightShouldBeForTileAt(2, 1, level.DirWest, 32, 32, 32) +} + +func (suite *WallHeightsSuite) TestCalculateFromSetsToMaximumHeightUnitFromOpenToSolidSide() { + suite.givenTile(1, 1).typed(level.TileTypeOpen) + suite.givenTile(2, 1).typed(level.TileTypeDiagonalOpenNorthEast) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightsShouldBeForTile(1, 1, 32) + suite.thenHeightShouldBeForTileAt(2, 1, level.DirWest, 32, 32, 32) +} + +func (suite *WallHeightsSuite) TestCalculateFromSetsHeightDifferencesForFloors() { + suite.givenTile(1, 1).typed(level.TileTypeOpen).floorAt(0) + suite.givenTile(1, 2).typed(level.TileTypeOpen).floorAt(10) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightShouldBeForTileAt(1, 1, level.DirNorth, 10, 10, 10) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, -10, -10, -10) +} + +func (suite *WallHeightsSuite) TestCalculateFromSetsHeightDifferencesForFloorsRespectingSlope() { + suite.givenTile(1, 1).typed(level.TileTypeSlopeEastToWest).floorAt(0).sloped(level.TileSlopeControlCeilingFlat, 2) + suite.givenTile(1, 2).typed(level.TileTypeOpen).floorAt(10) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightShouldBeForTileAt(1, 1, level.DirNorth, 8, 9, 10) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, -10, -9, -8) +} + +func (suite *WallHeightsSuite) TestCalculateFromSetsHeightDifferencesForFloorsRespectingSlopeSameFloorHeight() { + suite.givenTile(1, 1).typed(level.TileTypeOpen).floorAt(0) + suite.givenTile(1, 2).typed(level.TileTypeSlopeEastToWest).floorAt(0).sloped(level.TileSlopeControlCeilingFlat, 4) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightShouldBeForTileAt(1, 1, level.DirNorth, 4, 2, 0) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, 0, -2, -4) +} + +func (suite *WallHeightsSuite) TestCalculateFromConsidersCeilingHeightsIfWallingOff() { + suite.givenTile(1, 1).typed(level.TileTypeOpen).floorAt(0) + suite.givenTile(1, 2).typed(level.TileTypeSlopeSouthToNorth).floorAt(0).sloped(level.TileSlopeControlFloorFlat, 8).ceilingAt(8) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightShouldBeForTileAt(1, 1, level.DirNorth, 32, 32, 32) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, 32, 32, 32) +} + +func (suite *WallHeightsSuite) TestCalculateFromConsidersMirroredSlopesWallingOff() { + suite.givenTile(1, 1).typed(level.TileTypeOpen).floorAt(0) + suite.givenTile(1, 2).typed(level.TileTypeSlopeNorthToSouth).floorAt(0).sloped(level.TileSlopeControlCeilingMirrored, 16) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightShouldBeForTileAt(1, 1, level.DirNorth, 32, 32, 32) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, 32, 32, 32) +} + +func (suite *WallHeightsSuite) TestCalculateFromWithFloorCrossingOtherCeiling() { + suite.givenTile(1, 1).typed(level.TileTypeOpen).floorAt(20) + suite.givenTile(1, 2).typed(level.TileTypeOpen).floorAt(0).ceilingAt(8) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightShouldBeForTileAt(1, 1, level.DirNorth, 32, 32, 32) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, 32, 32, 32) +} + +func (suite *WallHeightsSuite) TestCalculateFromWithSameSlopes() { + suite.givenTile(1, 1).typed(level.TileTypeSlopeEastToWest).sloped(level.TileSlopeControlCeilingInverted, 8) + suite.givenTile(1, 2).typed(level.TileTypeSlopeEastToWest).sloped(level.TileSlopeControlCeilingInverted, 8) + suite.whenHeightsAreCalculatedFromMap() + suite.thenHeightShouldBeForTileAt(1, 1, level.DirNorth, 0, 0, 0) + suite.thenHeightShouldBeForTileAt(1, 2, level.DirSouth, 0, 0, 0) +} + +func (suite *WallHeightsSuite) givenTile(x, y int) *WallHeightsSuite { + suite.tile = suite.Tile(level.TilePosition{X: byte(x), Y: byte(y)}) + return suite +} + +func (suite *WallHeightsSuite) typed(tileType level.TileType) *WallHeightsSuite { + suite.tile.Type = tileType + return suite +} + +func (suite *WallHeightsSuite) sloped(ctrl level.TileSlopeControl, height level.TileHeightUnit) *WallHeightsSuite { + suite.tile.Flags = suite.tile.Flags.WithSlopeControl(ctrl) + suite.tile.SlopeHeight = height + return suite +} + +func (suite *WallHeightsSuite) floorAt(value level.TileHeightUnit) *WallHeightsSuite { + suite.tile.Floor = suite.tile.Floor.WithAbsoluteHeight(value) + return suite +} + +func (suite *WallHeightsSuite) ceilingAt(value level.TileHeightUnit) *WallHeightsSuite { + suite.tile.Ceiling = suite.tile.Ceiling.WithAbsoluteHeight(value) + return suite +} + +func (suite *WallHeightsSuite) givenARandomHeightsMap() { + var random float32 + randomizeSide := func(side *[3]float32) { + for i := 0; i < 3; i++ { + random += 0.5 + if random > 32 { + random = -32 + } + side[i] = random + } + } + randomizeTile := func(heights *level.WallHeights) { + randomizeSide(&heights.North) + randomizeSide(&heights.East) + randomizeSide(&heights.South) + randomizeSide(&heights.West) + } + + for _, row := range suite.heightsMap { + for i := 0; i < len(row); i++ { + heights := &row[i] + randomizeTile(heights) + } + } +} + +func (suite *WallHeightsSuite) whenHeightsAreCalculatedFromMap() { + suite.heightsMap.CalculateFrom(suite) +} + +func (suite *WallHeightsSuite) thenHeightsShouldBeForTile(x int, y int, expected float32) { + tile := suite.heightsMap.Tile(level.TilePosition{X: byte(x), Y: byte(y)}) + + verifySide := func(name string, values [3]float32) { + for i := 0; i < len(values); i++ { + assert.Equal(suite.T(), expected, values[i], fmt.Sprintf("%s[%d] mismatch", name, i)) + } + } + verifySide("North", tile.North) + verifySide("East", tile.East) + verifySide("South", tile.South) + verifySide("West", tile.West) +} + +func (suite *WallHeightsSuite) thenHeightShouldBeForTileAt(x int, y int, side level.Direction, + expectedLeft float32, expectedCenter float32, expectedRight float32) { + tile := suite.heightsMap.Tile(level.TilePosition{X: byte(x), Y: byte(y)}) + var values [3]float32 + + switch side { + case level.DirNorth: + values = tile.North + case level.DirEast: + values = tile.East + case level.DirSouth: + values = tile.South + case level.DirWest: + values = tile.West + default: + require.Fail(suite.T(), "Invalid side specified") + } + + assert.Equal(suite.T(), expectedLeft, values[0], "left mismatch") + assert.Equal(suite.T(), expectedCenter, values[1], "center mismatch") + assert.Equal(suite.T(), expectedRight, values[2], "right mismatch") +} + +func (suite *WallHeightsSuite) Tile(pos level.TilePosition) *level.TileMapEntry { + return suite.tileMap.Tile(pos, 2) +} diff --git a/modding/hacked/ss1/content/archive/level/WallTexturePattern.go b/modding/hacked/ss1/content/archive/level/WallTexturePattern.go new file mode 100644 index 0000000..55ab344 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/WallTexturePattern.go @@ -0,0 +1,40 @@ +package level + +import "fmt" + +// WallTexturePattern describes how a wall texture should be applied for a tile. +type WallTexturePattern byte + +// String returns the textual information. +func (pattern WallTexturePattern) String() string { + switch pattern { + case WallTexturePatternRegular: + return "Regular" + case WallTexturePatternFlipHorizontal: + return "FlipHorizontal" + case WallTexturePatternFlipAlternating: + return "FlipAlternating" + case WallTexturePatternFlipAlternatingInverted: + return "FlipAlternatingInverted" + default: + return fmt.Sprintf("Unknown%02X", int(pattern)) + } +} + +// WallTexturePattern constants are listed below. +const ( + WallTexturePatternRegular WallTexturePattern = 0 + WallTexturePatternFlipHorizontal WallTexturePattern = 1 + WallTexturePatternFlipAlternating WallTexturePattern = 2 + WallTexturePatternFlipAlternatingInverted WallTexturePattern = 3 +) + +// WallTexturePatterns returns all available texture patterns. +func WallTexturePatterns() []WallTexturePattern { + return []WallTexturePattern{ + WallTexturePatternRegular, + WallTexturePatternFlipHorizontal, + WallTexturePatternFlipAlternating, + WallTexturePatternFlipAlternatingInverted, + } +} diff --git a/modding/hacked/ss1/content/archive/level/lvlids/Identifier.go b/modding/hacked/ss1/content/archive/level/lvlids/Identifier.go new file mode 100644 index 0000000..6e2d11d --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/lvlids/Identifier.go @@ -0,0 +1,45 @@ +package lvlids + +const ( + // PerLevel is the amount of resources reserved per level. + PerLevel = 100 + + // FirstUsed identifies the first level-specific resource in a level-range. + FirstUsed = 2 + // FirstUnused identifies the first unused level-specific resource in a level-range. + FirstUnused = 54 +) + +// Level resource identifier are listed below. +const ( + MapVersionNumber = 2 + ObjectVersionNumber = 3 + Information = 4 + + TileMap = 5 + Schedules = 6 + TextureAtlas = 7 + ObjectMainTable = 8 + ObjectCrossRefTable = 9 + ObjectClassTablesStart = 10 + ObjectDefaultTablesStart = 25 + + SavefileVersion = 40 + Unused41 = 41 + + TextureAnimations = 42 + SurveillanceSources = 43 + SurveillanceSurrogates = 44 + Parameters = 45 + MapNotes = 46 + MapNotesPointer = 47 + + Unknown48 = 48 + Unknown49 = 49 + Unknown50 = 50 + + LoopConfiguration = 51 + + Unknown52 = 52 + HeightSemaphores = 53 +) diff --git a/modding/hacked/ss1/content/archive/level/lvlobj/Animating.go b/modding/hacked/ss1/content/archive/level/lvlobj/Animating.go new file mode 100644 index 0000000..09ee5d2 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/lvlobj/Animating.go @@ -0,0 +1,7 @@ +package lvlobj + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" +) + +var baseAnimation = interpreters.New() diff --git a/modding/hacked/ss1/content/archive/level/lvlobj/BigStuff.go b/modding/hacked/ss1/content/archive/level/lvlobj/BigStuff.go new file mode 100644 index 0000000..f52d97e --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/lvlobj/BigStuff.go @@ -0,0 +1,122 @@ +package lvlobj + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" +) + +var baseBigStuff = interpreters.New() + +var multiAnimation = interpreters.New(). + With("LoopType", 0, 2).As(interpreters.Bitfield(map[uint32]string{0x01: "Forward/Backward", 0x02: "Backward"})). + With("Alternation", 2, 2).As(interpreters.SpecialValue("MultiAnimation")). + With("Picture", 4, 2).As(interpreters.SpecialValue("PictureSource")). + With("Alternate", 6, 2).As(interpreters.SpecialValue("PictureSource")) + +var displayScenery = baseBigStuff. + With("FrameCount", 0, 2).As(interpreters.RangedValue(0, 8)). + Refining("", 2, 8, multiAnimation, interpreters.Always) + +var displayControlPedestal = baseBigStuff. + With("FrameCount", 0, 2).As(interpreters.RangedValue(0, 8)). + With("TriggerObjectID1", 2, 2).As(interpreters.ObjectID()). + With("TriggerObjectID2", 4, 2).As(interpreters.ObjectID()). + Refining("", 2, 8, multiAnimation, interpreters.Always) + +var cabinetFurniture = baseBigStuff. + With("Object1ID", 2, 2).As(interpreters.ObjectID()). + With("Object2ID", 4, 2).As(interpreters.ObjectID()) + +var texturableFurniture = baseBigStuff. + With("Texture", 6, 2).As(interpreters.SpecialValue("PictureSource")) + +var wordScenery = baseBigStuff. + With("TextIndex", 0, 2).As(interpreters.RangedValue(0, 511)). + With("Font", 2, 1).As(interpreters.Bitfield(map[uint32]string{ + 0x0F: "Face", + 0xF0: "Size"})). + With("Color", 4, 1).As(interpreters.RangedValue(0, 255)) + +var textureMapScenery = baseBigStuff. + With("TextureIndex", 6, 2).As(interpreters.SpecialValue("LevelTexture")) + +var buttonControlPedestal = baseBigStuff. + With("TriggerObjectID1", 2, 2).As(interpreters.ObjectID()). + With("TriggerObjectID2", 4, 2).As(interpreters.ObjectID()) + +var surgicalMachine = baseBigStuff. + With("BrokenState", 2, 1).As(interpreters.EnumValue(map[uint32]string{0x00: "OK", 0xE7: "Broken"})). + With("BrokenMessageIndex", 5, 1) + +var securityCamera = baseBigStuff. + With("PanningSwitch", 2, 1).As(interpreters.EnumValue(map[uint32]string{0: "Stationary", 1: "Panning"})) + +var solidBridge = baseBigStuff. + With("Size", 2, 1).As(interpreters.Bitfield(map[uint32]string{0x0F: "X", 0xF0: "Y"})). + With("Height", 3, 1).As(interpreters.SpecialValue("ObjectHeight")). + With("TopBottomTexture", 4, 1).As(interpreters.SpecialValue("MaterialOrLevelTexture")). + With("SideTexture", 5, 1).As(interpreters.SpecialValue("MaterialOrLevelTexture")) + +var forceBridge = baseBigStuff. + With("Size", 2, 1).As(interpreters.Bitfield(map[uint32]string{0x0F: "X", 0xF0: "Y"})). + With("Height", 3, 1).As(interpreters.SpecialValue("ObjectHeight")). + With("Color", 6, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) (result string) { + return forceColors[value] + })) + +func initBigStuff() interpreterRetriever { + displays := newInterpreterLeaf(displayScenery) + textureable := newInterpreterLeaf(texturableFurniture) + + electronics := newInterpreterEntry(baseBigStuff) + electronics.set(6, displays) + electronics.set(7, displays) + + furniture := newInterpreterEntry(baseBigStuff) + furniture.set(2, newInterpreterLeaf(cabinetFurniture)) + furniture.set(5, textureable) + furniture.set(7, textureable) + furniture.set(8, textureable) + + surfaces := newInterpreterEntry(baseBigStuff) + surfaces.set(3, newInterpreterLeaf(wordScenery)) + surfaces.set(6, displays) + surfaces.set(7, newInterpreterLeaf(textureMapScenery)) + surfaces.set(8, displays) + surfaces.set(9, displays) + + lighting := newInterpreterEntry(baseBigStuff) + + medicalEquipment := newInterpreterEntry(baseBigStuff) + medicalEquipment.set(0, newInterpreterLeaf(buttonControlPedestal)) + medicalEquipment.set(3, newInterpreterLeaf(surgicalMachine)) + medicalEquipment.set(5, textureable) + + scienceSecurityEquipment := newInterpreterEntry(baseBigStuff) + scienceSecurityEquipment.set(4, newInterpreterLeaf(securityCamera)) + scienceSecurityEquipment.set(5, newInterpreterLeaf(buttonControlPedestal)) + scienceSecurityEquipment.set(6, newInterpreterLeaf(displayControlPedestal)) + + gardenScenery := newInterpreterEntry(baseBigStuff) + + bridges := newInterpreterEntry(baseBigStuff) + solidBridges := newInterpreterLeaf(solidBridge) + forceBridges := newInterpreterLeaf(forceBridge) + bridges.set(0, solidBridges) + bridges.set(1, solidBridges) + bridges.set(7, forceBridges) + bridges.set(8, forceBridges) + bridges.set(9, forceBridges) + + class := newInterpreterEntry(baseBigStuff) + class.set(0, electronics) + class.set(1, furniture) + class.set(2, surfaces) + class.set(3, lighting) + class.set(4, medicalEquipment) + class.set(5, scienceSecurityEquipment) + class.set(6, gardenScenery) + class.set(7, bridges) + + return class +} diff --git a/modding/hacked/ss1/content/archive/level/lvlobj/Containers.go b/modding/hacked/ss1/content/archive/level/lvlobj/Containers.go new file mode 100644 index 0000000..f2d5844 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/lvlobj/Containers.go @@ -0,0 +1,36 @@ +package lvlobj + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" +) + +var baseContainer = interpreters.New() + +var standardContainer = baseContainer. + With("ObjectID1", 0, 2).As(interpreters.ObjectID()). + With("ObjectID2", 2, 2).As(interpreters.ObjectID()). + With("ObjectID3", 4, 2).As(interpreters.ObjectID()). + With("ObjectID4", 6, 2).As(interpreters.ObjectID()) + +var crate = standardContainer. + With("Width", 8, 1). + With("Depth", 9, 1). + With("Height", 10, 1). + With("TopBottomTexture", 11, 1). + With("SideTexture", 12, 1) + +func initContainers() interpreterRetriever { + standardContainers := newInterpreterLeaf(standardContainer) + crates := newInterpreterLeaf(crate) + + class := newInterpreterEntry(baseContainer) + class.set(0, crates) + class.set(1, standardContainers) + class.set(2, standardContainers) + class.set(3, standardContainers) + class.set(4, standardContainers) + class.set(5, standardContainers) + class.set(6, standardContainers) + + return class +} diff --git a/modding/hacked/ss1/content/archive/level/lvlobj/Critters.go b/modding/hacked/ss1/content/archive/level/lvlobj/Critters.go new file mode 100644 index 0000000..2914eef --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/lvlobj/Critters.go @@ -0,0 +1,40 @@ +package lvlobj + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" +) + +var baseCritter = interpreters.New(). + With("PendingZRotation", 0, 4). + With("ForwardVelocityFraction", 4, 2). + With("ForwardVelocity", 6, 2). + With("Hastiness", 0x0A, 2). + With("StateTimeout", 0x0C, 2).As(interpreters.RangedValue(0, 300)). + With("Unknown000E", 0x0E, 2).As(interpreters.SpecialValue("Unknown")). + With("Unknown0010", 0x10, 2).As(interpreters.SpecialValue("Unknown")). + With("RoamingState", 0x14, 1). + With("PrimaryState", 0x15, 1).As(interpreters.EnumValue(map[uint32]string{ + 0: "docile", + 1: "cautious", + 2: "hostile", + 3: "cautious (?)", + 4: "attacking", + 5: "sleeping", + 6: "tranquilized", + 7: "confused"})). + With("SecondaryState", 0x16, 1). + With("TertiaryState", 0x17, 1). + With("AICoordinates0018", 0x18, 2).As(interpreters.SpecialValue("Unknown")). + With("AICoordinates001A", 0x1A, 2).As(interpreters.SpecialValue("Unknown")). + With("AICoordinates001C", 0x1C, 2).As(interpreters.SpecialValue("Unknown")). + With("Always255", 0x1E, 1).As(interpreters.RangedValue(0, 255)). + With("AICoordinates001F", 0x1F, 1).As(interpreters.SpecialValue("Unknown")). + With("LootObjectID1", 0x20, 2).As(interpreters.ObjectID()). + With("LootObjectID2", 0x22, 2).As(interpreters.ObjectID()). + With("Unknown0026", 0x26, 2).As(interpreters.SpecialValue("Unknown")) + +func initCritters() interpreterRetriever { + class := newInterpreterEntry(baseCritter) + + return class +} diff --git a/modding/hacked/ss1/content/archive/level/lvlobj/Doors.go b/modding/hacked/ss1/content/archive/level/lvlobj/Doors.go new file mode 100644 index 0000000..ef156f0 --- /dev/null +++ b/modding/hacked/ss1/content/archive/level/lvlobj/Doors.go @@ -0,0 +1,38 @@ +package lvlobj + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/content/interpreters" +) + +var baseDoor = interpreters.New(). + With("LockVariableIndex", 0, 2).As(interpreters.SpecialValue("LockVariable")). + With("LockMessageIndex", 2, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) (result string) { + return fmt.Sprintf("%d", value+7) + })). + With("ForceDoorColor", 3, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) (result string) { + return forceColors[value] + })). + With("RequiredAccessLevel", 4, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) (result string) { + if value == 255 { + result = "SHODAN" + } else if accessLevel, known := accessLevelMasks[1<> 8), byte(dataBytes >> 16)}) + return err +} + +func writeBasicSoundData(writer io.Writer, sampleRate float32, samples []byte) error { + sampleType := byte(0) + + err := writeBlockHeader(writer, soundData, len(samples)+2) + if err != nil { + return err + } + _, err = writer.Write([]byte{sampleRateToDivisor(sampleRate), sampleType}) + if err != nil { + return err + } + _, err = writer.Write(samples) + return err +} + +func writeEndOfFile(writer io.Writer) error { + _, err := writer.Write([]byte{byte(terminator)}) + return err +} diff --git a/modding/hacked/ss1/content/audio/voc/Utility.go b/modding/hacked/ss1/content/audio/voc/Utility.go new file mode 100644 index 0000000..6f61c2d --- /dev/null +++ b/modding/hacked/ss1/content/audio/voc/Utility.go @@ -0,0 +1,22 @@ +package voc + +const ( + // FileHeader identifies VOC files. + FileHeader string = "Creative Voice File\u001A" + standardHeaderSize uint16 = 0x1A + baseVersion uint16 = 0x010A + versionCheckValue uint16 = 0x1234 + rateBase float32 = 1000000.0 +) + +func lengthFromBlockStart(blockStart []byte) int { + return int(blockStart[3])<<16 + int(blockStart[2])<<8 + int(blockStart[1]) +} + +func divisorToSampleRate(divisor byte) float32 { + return rateBase / float32(256-int(divisor)) +} + +func sampleRateToDivisor(sampleRate float32) byte { + return byte(256 - int(rateBase/sampleRate)) +} diff --git a/modding/hacked/ss1/content/audio/voc/Utility_test.go b/modding/hacked/ss1/content/audio/voc/Utility_test.go new file mode 100644 index 0000000..4e4f549 --- /dev/null +++ b/modding/hacked/ss1/content/audio/voc/Utility_test.go @@ -0,0 +1,23 @@ +package voc // nolint: testpackage + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFrequencyDivisorFor11111(t *testing.T) { + divisor := byte(0xA6) + sampleRate := divisorToSampleRate(divisor) + result := sampleRateToDivisor(sampleRate) + + assert.Equal(t, divisor, result) +} + +func TestFrequencyDivisorFor222222(t *testing.T) { + divisor := byte(0x2D) + sampleRate := divisorToSampleRate(divisor) + result := sampleRateToDivisor(sampleRate) + + assert.Equal(t, divisor, result) +} diff --git a/modding/hacked/ss1/content/audio/wav/Load.go b/modding/hacked/ss1/content/audio/wav/Load.go new file mode 100644 index 0000000..614f3c0 --- /dev/null +++ b/modding/hacked/ss1/content/audio/wav/Load.go @@ -0,0 +1,32 @@ +package wav + +import ( + "io" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/content/audio" +) + +const ( + errSourceIsNil ss1.StringError = "source is nil" + errNotASupportedWave ss1.StringError = "not a supported WAV" +) + +// Load reads from the provided source and returns the data. +func Load(source io.Reader) (data audio.L8, err error) { + if source == nil { + return data, errSourceIsNil + } + + var loader waveLoader + + loader.load(source) + if loader.err != nil { + return data, loader.err + } + + data.SampleRate = loader.sampleRate + data.Samples = loader.samples + + return +} diff --git a/modding/hacked/ss1/content/audio/wav/Load_test.go b/modding/hacked/ss1/content/audio/wav/Load_test.go new file mode 100644 index 0000000..fbdc930 --- /dev/null +++ b/modding/hacked/ss1/content/audio/wav/Load_test.go @@ -0,0 +1,65 @@ +package wav_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inkyblackness/hacked/ss1/content/audio/wav" +) + +func TestLoadReturnsErrorOnNil(t *testing.T) { + _, err := wav.Load(nil) + + assert.NotNil(t, err) +} + +func TestLoadExtractsDataOfL8(t *testing.T) { + input := []byte{ + 0x52, 0x49, 0x46, 0x46, // "RIFF" + 0x29, 0x00, 0x00, 0x00, // len(RIFF) + 0x57, 0x41, 0x56, 0x45, // "WAVE" + 0x66, 0x6d, 0x74, 0x20, // "fmt " + 0x10, 0x00, 0x00, 0x00, // len(fmt) + 0x01, 0x00, // fmt:type + 0x01, 0x00, // fmt:channels + 0x22, 0x56, 0x00, 0x00, // fmt:samples/sec + 0x22, 0x56, 0x00, 0x00, // fmt:avgBytes/sec + 0x01, 0x00, // fmt:blockAlign + 0x08, 0x00, // fmt:bits/sample + 0x64, 0x61, 0x74, 0x61, // "data" + 0x05, 0x00, 0x00, 0x00, // len(data) + 0x00, 0x40, 0x80, 0xC0, 0xFF} // data + + data, err := wav.Load(bytes.NewReader(input)) + + require.Nil(t, err) + assert.Equal(t, float32(22050), data.SampleRate) + assert.Equal(t, []byte{0x00, 0x40, 0x80, 0xC0, 0xFF}, data.Samples) +} + +func TestLoadExtractsDataOfL16(t *testing.T) { + input := []byte{ + 0x52, 0x49, 0x46, 0x46, // "RIFF" + 0x2E, 0x00, 0x00, 0x00, // len(RIFF) + 0x57, 0x41, 0x56, 0x45, // "WAVE" + 0x66, 0x6d, 0x74, 0x20, // "fmt " + 0x10, 0x00, 0x00, 0x00, // len(fmt) + 0x01, 0x00, // fmt:type + 0x01, 0x00, // fmt:channels + 0x22, 0x56, 0x00, 0x00, // fmt:samples/sec + 0x44, 0xAC, 0x00, 0x00, // fmt:avgBytes/sec + 0x02, 0x00, // fmt:blockAlign + 0x10, 0x00, // fmt:bits/sample + 0x64, 0x61, 0x74, 0x61, // "data" + 0x0A, 0x00, 0x00, 0x00, // len(data) + 0xAA, 0x00, 0xAA, 0x40, 0xAA, 0x7F, 0xAA, 0xC0, 0xAA, 0xFF} // data + + data, err := wav.Load(bytes.NewReader(input)) + + require.Nil(t, err) + assert.Equal(t, float32(22050), data.SampleRate) + assert.Equal(t, []byte{0x80, 0xC0, 0xFF, 0x40, 0x7F}, data.Samples) +} diff --git a/modding/hacked/ss1/content/audio/wav/Save.go b/modding/hacked/ss1/content/audio/wav/Save.go new file mode 100644 index 0000000..c5baddb --- /dev/null +++ b/modding/hacked/ss1/content/audio/wav/Save.go @@ -0,0 +1,35 @@ +package wav + +import ( + "encoding/binary" + "io" +) + +// Save encodes the provided samples into the given writer. +func Save(writer io.Writer, sampleRate float32, samples []byte) error { + dataSize := uint32(len(samples)) + var fmt formatHeader + fmtSize := fmt.size() + contentType := riffContentTypeWave + contentTypeSize := uint32(4) + tagSize := uint32(8) + tagSizes := tagSize * 2 + + fmt.base.FormatType = waveFormatTypePcm + fmt.base.Channels = 1 + fmt.base.SamplesPerSec = uint32(sampleRate) + fmt.base.AvgBytesPerSec = fmt.base.SamplesPerSec + fmt.base.BlockAlign = 1 + fmt.extension.BitsPerSample = 8 + + riffTag := riffChunkTag{ChunkType: riffChunkTypeRiff, Size: tagSizes + contentTypeSize + fmtSize + dataSize} + + _ = binary.Write(writer, binary.LittleEndian, &riffTag) + _ = binary.Write(writer, binary.LittleEndian, &contentType) + _ = binary.Write(writer, binary.LittleEndian, &riffChunkTag{ChunkType: riffChunkTypeFmt, Size: fmtSize}) + _ = binary.Write(writer, binary.LittleEndian, &fmt.base) + _ = binary.Write(writer, binary.LittleEndian, &fmt.extension) + _ = binary.Write(writer, binary.LittleEndian, &riffChunkTag{ChunkType: riffChunkTypeData, Size: dataSize}) + _, err := writer.Write(samples) + return err +} diff --git a/modding/hacked/ss1/content/audio/wav/Save_test.go b/modding/hacked/ss1/content/audio/wav/Save_test.go new file mode 100644 index 0000000..b71f575 --- /dev/null +++ b/modding/hacked/ss1/content/audio/wav/Save_test.go @@ -0,0 +1,37 @@ +package wav_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inkyblackness/hacked/ss1/content/audio/wav" +) + +func TestSaveStoresBytes(t *testing.T) { + sampleRate := float32(22050.0) + samples := []byte{0x00, 0x40, 0x80, 0xC0, 0xFF} + buf := bytes.NewBuffer(nil) + + err := wav.Save(buf, sampleRate, samples) + + require.Nil(t, err) + assert.Equal(t, []byte{ + 0x52, 0x49, 0x46, 0x46, // "RIFF" + 0x2B, 0x00, 0x00, 0x00, // len(RIFF) + 0x57, 0x41, 0x56, 0x45, // "WAVE" + 0x66, 0x6d, 0x74, 0x20, // "fmt " + 0x12, 0x00, 0x00, 0x00, // len(fmt) + 0x01, 0x00, // fmt:type + 0x01, 0x00, // fmt:channels + 0x22, 0x56, 0x00, 0x00, // fmt:samples/sec + 0x22, 0x56, 0x00, 0x00, // fmt:avgBytes/sec + 0x01, 0x00, // fmt:blockAlign + 0x08, 0x00, // fmt:bits/sample + 0x00, 0x00, // fmt:extensionSize + 0x64, 0x61, 0x74, 0x61, // "data" + 0x05, 0x00, 0x00, 0x00, // len(data) + 0x00, 0x40, 0x80, 0xC0, 0xFF}, buf.Bytes()) +} diff --git a/modding/hacked/ss1/content/audio/wav/WaveLoader.go b/modding/hacked/ss1/content/audio/wav/WaveLoader.go new file mode 100644 index 0000000..8ee411f --- /dev/null +++ b/modding/hacked/ss1/content/audio/wav/WaveLoader.go @@ -0,0 +1,133 @@ +package wav + +import ( + "bytes" + "encoding/binary" + "io" + + "github.com/inkyblackness/hacked/ss1" +) + +const ( + errUnsupportedFormat ss1.StringError = "unsupported WAVE format" +) + +func l8FromL8(input []byte) []byte { + return input +} + +func l8FromL16(input []byte) []byte { + samples := len(input) / 2 + output := make([]byte, samples) + + for i := 0; i < samples; i++ { + output[i] = byte(int(input[i*2+1]) + 0x80) + } + + return output +} + +type waveLoader struct { + dataRead bool + formatRead bool + samples []byte + sampleRate float32 + + reader io.Reader + err error + + dataConverter func([]byte) []byte +} + +func (loader *waveLoader) load(reader io.Reader) { + loader.reader = reader + loader.dataConverter = l8FromL8 + loader.loadRiff() +} + +func (loader *waveLoader) read(data interface{}) bool { + if loader.err == nil { + loader.err = binary.Read(loader.reader, binary.LittleEndian, data) + } + return loader.err == nil +} + +func (loader *waveLoader) readBytes(size uint32) (data []byte) { + data = make([]byte, int(size)) + _, loader.err = loader.reader.Read(data) + return +} + +func (loader *waveLoader) loadChunk(handler func(riffChunkType, uint32)) { + var tag riffChunkTag + + if loader.read(&tag) { + handler(tag.ChunkType, tag.Size) + } +} + +func (loader *waveLoader) loadRiff() { + loader.loadChunk(loader.handleRiff) +} + +func (loader *waveLoader) handleRiff(chunkType riffChunkType, size uint32) { + if chunkType == riffChunkTypeRiff { + var contentType riffContentType + if loader.read(&contentType) && (contentType == riffContentTypeWave) { + loader.loadWave() + } else if loader.err == nil { + loader.err = errNotASupportedWave + } + } else { + loader.err = errNotASupportedWave + } +} + +func (loader *waveLoader) loadWave() { + for !loader.isDone() { + loader.loadFormatOrData() + } + loader.samples = loader.dataConverter(loader.samples) +} + +func (loader *waveLoader) loadFormatOrData() { + loader.loadChunk(loader.handleFormatOrData) +} + +func (loader *waveLoader) handleFormatOrData(chunkType riffChunkType, size uint32) { + if chunkType == riffChunkTypeFmt { + loader.loadFormat(size) + } else if chunkType == riffChunkTypeData { + loader.loadData(size) + } +} + +func (loader *waveLoader) loadFormat(size uint32) { + headerData := loader.readBytes(size) + headerReader := bytes.NewReader(headerData) + var header formatHeader + + loader.formatRead = true + loader.err = binary.Read(headerReader, binary.LittleEndian, &header.base) + loader.err = binary.Read(headerReader, binary.LittleEndian, &header.extension.BitsPerSample) + loader.sampleRate = float32(header.base.SamplesPerSec) + + if header.extension.BitsPerSample == 16 { + loader.dataConverter = l8FromL16 + } + + if (header.base.FormatType != waveFormatTypePcm) || + (header.base.Channels != 1) || + ((header.extension.BitsPerSample != 8) && (header.extension.BitsPerSample != 16)) { + loader.err = errUnsupportedFormat + } +} + +func (loader *waveLoader) loadData(size uint32) { + loader.dataRead = true + loader.samples = loader.readBytes(size) +} + +func (loader *waveLoader) isDone() bool { + return (loader.err != nil) || (loader.dataRead && loader.formatRead) +} diff --git a/modding/hacked/ss1/content/audio/wav/formatHeader.go b/modding/hacked/ss1/content/audio/wav/formatHeader.go new file mode 100644 index 0000000..089e769 --- /dev/null +++ b/modding/hacked/ss1/content/audio/wav/formatHeader.go @@ -0,0 +1,33 @@ +package wav + +import ( + "encoding/binary" +) + +type waveFormatType uint16 + +const ( + waveFormatTypePcm = 1 +) + +type waveFormat struct { + FormatType waveFormatType + Channels uint16 + SamplesPerSec uint32 + AvgBytesPerSec uint32 + BlockAlign uint16 +} + +type waveFormatExtension struct { + BitsPerSample uint16 + ExtensionSize uint16 +} + +type formatHeader struct { + base waveFormat + extension waveFormatExtension +} + +func (header formatHeader) size() uint32 { + return uint32(binary.Size(&header.base) + binary.Size(&header.extension)) +} diff --git a/modding/hacked/ss1/content/audio/wav/riff.go b/modding/hacked/ss1/content/audio/wav/riff.go new file mode 100644 index 0000000..7fd564a --- /dev/null +++ b/modding/hacked/ss1/content/audio/wav/riff.go @@ -0,0 +1,19 @@ +package wav + +type riffChunkType uint32 +type riffContentType uint32 + +const ( + riffChunkTypeRiff = riffChunkType(0x46464952) + riffChunkTypeFmt = riffChunkType(0x20746d66) + riffChunkTypeData = riffChunkType(0x61746164) +) + +const ( + riffContentTypeWave = riffContentType(0x45564157) +) + +type riffChunkTag struct { + ChunkType riffChunkType + Size uint32 +} diff --git a/modding/hacked/ss1/content/bitmap/Animation.go b/modding/hacked/ss1/content/bitmap/Animation.go new file mode 100644 index 0000000..dc5aa55 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Animation.go @@ -0,0 +1,98 @@ +package bitmap + +import ( + "fmt" + "io" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial" +) + +const ( + animationEndTag = 0x0C + animationEndTagData = 0x01 + + animationEntryTag = 0x04 + + errEndTagDataWrong ss1.StringError = "end tag data wrong" +) + +type unknownEntryTagError struct { + tag byte +} + +func (err unknownEntryTagError) Error() string { + return fmt.Sprintf("unknown entry tag 0x%02X", int(err.tag)) +} + +// Animation describes a sequence of bitmaps to form a small movie. +type Animation struct { + Width int16 + Height int16 + ResourceID resource.ID + IntroFlag uint16 + Entries []AnimationEntry +} + +// AnimationEntry describes one part of an animation with common frame time. +type AnimationEntry struct { + FirstFrame byte + LastFrame byte + FrameTime int16 +} + +// ReadAnimation extracts an animation from a serialized stream. +func ReadAnimation(reader io.Reader) (anim Animation, err error) { + decoder := serial.NewDecoder(reader) + codeAnimationBase(decoder, &anim) + done := false + err = decoder.FirstError() + for !done && (err == nil) { + var tag byte + decoder.Code(&tag) + err = decoder.FirstError() + switch tag { + case animationEntryTag: + var entry AnimationEntry + decoder.Code(&entry) + anim.Entries = append(anim.Entries, entry) + err = decoder.FirstError() + case animationEndTag: + var endTagData byte + decoder.Code(&endTagData) + if endTagData != animationEndTagData { + err = errEndTagDataWrong + } + done = true + default: + if err == nil { + err = unknownEntryTagError{tag: tag} + } + } + } + + return +} + +// WriteAnimation serializes an animation to a writer. +func WriteAnimation(writer io.Writer, anim Animation) error { + encoder := serial.NewEncoder(writer) + codeAnimationBase(encoder, &anim) + for _, entry := range anim.Entries { + encoder.Code([]byte{animationEntryTag}) + encoder.Code(&entry) // nolint: scopelint + } + encoder.Code([]byte{animationEndTag, animationEndTagData}) + + return encoder.FirstError() +} + +func codeAnimationBase(coder serial.Coder, anim *Animation) { + coder.Code(&anim.Width) + coder.Code(&anim.Height) + coder.Code(&anim.ResourceID) + var unknown [6]byte + coder.Code(&unknown) + coder.Code(&anim.IntroFlag) +} diff --git a/modding/hacked/ss1/content/bitmap/AnimationCache.go b/modding/hacked/ss1/content/bitmap/AnimationCache.go new file mode 100644 index 0000000..ffbde1f --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/AnimationCache.go @@ -0,0 +1,58 @@ +package bitmap + +import ( + "github.com/inkyblackness/hacked/ss1/resource" +) + +// AnimationCache retrieves animations from a localizer and keeps them decoded until they are invalidated. +type AnimationCache struct { + localizer resource.Localizer + + animations map[resource.Key]Animation +} + +// NewAnimationCache returns a new instance. +func NewAnimationCache(localizer resource.Localizer) *AnimationCache { + cache := &AnimationCache{ + localizer: localizer, + animations: make(map[resource.Key]Animation), + } + return cache +} + +// InvalidateResources lets the cache remove any animations from resources that are specified in the given slice. +func (cache *AnimationCache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key := range cache.animations { + if key.ID == id { + delete(cache.animations, key) + } + } + } +} + +// Animation tries to look up given animation. +func (cache *AnimationCache) Animation(key resource.Key) (anim Animation, err error) { + anim, existing := cache.animations[key] + if existing { + return anim, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + view, err := selector.Select(key.ID) + if err != nil { + return + } + if (view.ContentType() != resource.Animation) || (view.BlockCount() != 1) { + return anim, resource.ErrWrongType(key, resource.Animation) + } + reader, err := view.Block(0) + if err != nil { + return + } + anim, err = ReadAnimation(reader) + if err != nil { + return + } + cache.animations[key] = anim + return anim, nil +} diff --git a/modding/hacked/ss1/content/bitmap/AnimationCache_test.go b/modding/hacked/ss1/content/bitmap/AnimationCache_test.go new file mode 100644 index 0000000..ba634b6 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/AnimationCache_test.go @@ -0,0 +1,176 @@ +package bitmap_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/resource" +) + +const ( + baseAnimationResourceID = 0x0A4C +) + +type AnimationCacheSuite struct { + suite.Suite + + localizedResources resource.LocalizedResourcesList + + instance *bitmap.AnimationCache +} + +func TestAnimationCacheSuite(t *testing.T) { + suite.Run(t, new(AnimationCacheSuite)) +} + +func (suite *AnimationCacheSuite) SetupTest() { + suite.instance = nil +} + +func (suite *AnimationCacheSuite) TestAnimationReturnsValueIfOK() { + anim := suite.someAnimation(0) + suite.givenAnInstance() + suite.whenResourcesAre( + suite.someLocalizedResources( + suite.storing(0, anim))) + suite.thenAnimationShouldReturn(anim, 0) +} + +func (suite *AnimationCacheSuite) TestAnimationReturnsErrorIfResourceNotExisting() { + suite.givenAnInstance() + suite.whenResourcesAre() + suite.thenAnimationShouldReturnError(0) +} + +func (suite *AnimationCacheSuite) TestTextTriesToReloadWhenCacheInvalidated() { + suite.givenAnInstance() + suite.givenResourcesAre( + suite.someLocalizedResources( + suite.storing(0, suite.someAnimation(0)))) + suite.givenAnimationWasRetrieved(0) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(baseAnimationResourceID) + suite.thenAnimationShouldReturnError(0) +} + +func (suite *AnimationCacheSuite) TestInvalidationConsidersOnlyAffectedIDs() { + suite.givenAnInstance() + suite.givenResourcesAre( + suite.someLocalizedResources( + suite.storing(0, suite.someAnimation(0)), suite.storing(1, suite.someAnimation(1)))) + suite.givenAnimationWasRetrieved(0) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(0x02BD) + suite.thenAnimationShouldReturn(suite.someAnimation(0), 0) +} + +func (suite *AnimationCacheSuite) TestAnimationReturnsErrorIfResourceIsNotAnAnimation() { + suite.givenAnInstance() + suite.whenResourcesAre(suite.someLocalizedResources(suite.storingNonAnimation(0))) + suite.thenAnimationShouldReturnError(0) +} + +func (suite *AnimationCacheSuite) givenAnInstance() { + suite.instance = bitmap.NewAnimationCache(suite) +} + +func (suite *AnimationCacheSuite) givenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *AnimationCacheSuite) givenAnimationWasRetrieved(index int) { + _, err := suite.instance.Animation(suite.keyed(index)) + require.Nil(suite.T(), err, "No error expected setting up") +} + +func (suite *AnimationCacheSuite) whenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *AnimationCacheSuite) whenCacheResourcesAreInvalidated(ids ...resource.ID) { + suite.instance.InvalidateResources(ids) +} + +func (suite *AnimationCacheSuite) thenAnimationShouldReturn(expected bitmap.Animation, index int) { + result, err := suite.instance.Animation(suite.keyed(index)) + require.Nil(suite.T(), err, "No error expected") + assert.Equal(suite.T(), expected, result, "Animations don't match") +} + +func (suite *AnimationCacheSuite) thenAnimationShouldReturnError(index int) { + _, err := suite.instance.Animation(suite.keyed(index)) + require.NotNil(suite.T(), err, "Error expected") +} + +func (suite AnimationCacheSuite) keyed(index int) resource.Key { + return resource.KeyOf(baseAnimationResourceID, resource.LangAny, index) +} + +func (suite *AnimationCacheSuite) someLocalizedResources(modifiers ...func(*resource.Store)) resource.LocalizedResources { + var store resource.Store + for _, modifier := range modifiers { + modifier(&store) + } + return resource.LocalizedResources{ + ID: "unnamed", + Language: resource.LangAny, + Viewer: store, + } +} + +func (suite *AnimationCacheSuite) storing(id int, anim bitmap.Animation) func(*resource.Store) { + buf := bytes.NewBuffer(nil) + _ = bitmap.WriteAnimation(buf, anim) + return func(store *resource.Store) { + _ = store.Put(resource.ID(baseAnimationResourceID).Plus(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Animation, + Compound: true, + }, + Blocks: resource.BlocksFrom([][]byte{buf.Bytes()}), + }) + } +} + +func (suite *AnimationCacheSuite) someAnimation(seed uint8) bitmap.Animation { + anim := bitmap.Animation{ + Width: int16(seed) * 4, + Height: int16(seed) * 3, + IntroFlag: uint16(seed % 2), + ResourceID: resource.ID(seed), + Entries: make([]bitmap.AnimationEntry, seed+1), + } + + for i := 0; i < len(anim.Entries); i++ { + anim.Entries[i] = bitmap.AnimationEntry{ + FirstFrame: byte(i), + LastFrame: byte(i), + FrameTime: 100, + } + } + return anim +} + +func (suite *AnimationCacheSuite) storingNonAnimation(id int) func(*resource.Store) { + return func(store *resource.Store) { + _ = store.Put(resource.ID(baseAnimationResourceID).Plus(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Sound, + Compound: true, + }, + Blocks: resource.BlocksFrom([][]byte{{}}), + }) + } +} + +func (suite *AnimationCacheSuite) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + From: suite.localizedResources, + Lang: lang, + } +} diff --git a/modding/hacked/ss1/content/bitmap/Animation_test.go b/modding/hacked/ss1/content/bitmap/Animation_test.go new file mode 100644 index 0000000..d0e3bb1 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Animation_test.go @@ -0,0 +1,130 @@ +package bitmap_test + +import ( + "bytes" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/resource" +) + +func TestReadAnimationErrorsLength(t *testing.T) { + for i := 0; i < 20; i++ { + data := make([]byte, i) + _, err := bitmap.ReadAnimation(bytes.NewReader(data)) + assert.Error(t, err, fmt.Sprintf("Error expected for %d bytes", i)) + } +} + +func TestReadAnimationErrorsTags(t *testing.T) { + tt := []struct { + info string + data []byte + }{ + { + info: "end tag data missing", + data: []byte{ + 0x00, 0x00, + 0x02, 0x02, + 0x04, 0x04, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x0C, 0x0C, + 0x0C, + }, + }, + { + info: "end tag data wrong", + data: []byte{ + 0x00, 0x00, + 0x02, 0x02, + 0x04, 0x04, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x0C, 0x0C, + 0x0C, 0x00, + }, + }, + { + info: "unknown tag", + data: []byte{ + 0x00, 0x00, + 0x02, 0x02, + 0x04, 0x04, + 0x06, 0x06, 0x06, 0x06, 0x06, 0x06, + 0x0C, 0x0C, + 0x0B, + }, + }, + } + for _, tc := range tt { + _, err := bitmap.ReadAnimation(bytes.NewReader(tc.data)) + assert.Error(t, err, fmt.Sprintf("Error expected for %s", tc.info)) + } +} + +func TestReadWriteAnimationEntries(t *testing.T) { + tt := []struct { + info string + data []byte + expected bitmap.Animation + }{ + { + info: "empty list of entries", + data: []byte{ + 0x12, 0x34, + 0x56, 0x78, + 0xAA, 0xBB, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xCC, 0xDD, + 0x0C, 0x01, + }, + expected: bitmap.Animation{ + Width: 0x3412, + Height: 0x7856, + ResourceID: resource.ID(0xBBAA), + IntroFlag: 0xDDCC, + Entries: nil, + }, + }, + + { + info: "two entries", + data: []byte{ + 0x12, 0x34, + 0x56, 0x78, + 0xAA, 0xBB, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xCC, 0xDD, + 0x04, 0x00, 0x0A, 0x10, 0x20, + 0x04, 0x0B, 0x10, 0x30, 0x40, + 0x0C, 0x01, + }, + expected: bitmap.Animation{ + Width: 0x3412, + Height: 0x7856, + ResourceID: resource.ID(0xBBAA), + IntroFlag: 0xDDCC, + Entries: []bitmap.AnimationEntry{ + {FirstFrame: 0x00, LastFrame: 0x0A, FrameTime: 0x2010}, + {FirstFrame: 0x0B, LastFrame: 0x10, FrameTime: 0x4030}, + }, + }, + }, + } + for _, tc := range tt { + td := tc + t.Run(tc.info, func(t *testing.T) { + anim, err := bitmap.ReadAnimation(bytes.NewReader(td.data)) + require.Nil(t, err, "No error expected") + assert.Equal(t, td.expected, anim, "Animation data not as expected after reading") + + output := bytes.NewBuffer(nil) + err = bitmap.WriteAnimation(output, anim) + require.Nil(t, err, "No error expected") + assert.Equal(t, td.data, output.Bytes(), "Data mismatch after re-encoding") + }) + } +} diff --git a/modding/hacked/ss1/content/bitmap/Bitmap.go b/modding/hacked/ss1/content/bitmap/Bitmap.go new file mode 100644 index 0000000..affc350 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Bitmap.go @@ -0,0 +1,101 @@ +package bitmap + +import ( + "bytes" + "encoding/binary" + "io" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/serial/rle" +) + +const ( + errReaderIsNil ss1.StringError = "reader is nil" + errStrideNotValid ss1.StringError = "stride not equal to width for compressed bitmap" + errDataCouldNotBeRead ss1.StringError = "data could not be read" + errPaletteCouldNotBeRead ss1.StringError = "palette could not be read" +) + +// Bitmap describes a palette based image. +type Bitmap struct { + Header Header + Pixels []byte + Palette *Palette +} + +// Decode tries to read a bitmap from given reader. +// Should the bitmap be a compressed bitmap, then a reference image with pixel data all 0x00 is assumed. +func Decode(reader io.Reader) (*Bitmap, error) { + return DecodeReferenced(reader, func(width, height int16) ([]byte, error) { + return make([]byte, int(width)*int(height)), nil + }) +} + +// DecodeReferenced tries to read a bitmap from given reader. +// If the serialized bitmap describes a compressed bitmap, then the pixels from the reference are used as a basis for the result. +// The returned byte array from the provider will be used as pixel buffer for the new bitmap. +func DecodeReferenced(reader io.Reader, provider func(width, height int16) ([]byte, error)) (*Bitmap, error) { + if reader == nil { + return nil, errReaderIsNil + } + + var bmp Bitmap + + err := binary.Read(reader, binary.LittleEndian, &bmp.Header) + if err != nil { + return nil, err + } + if bmp.Header.Type == TypeCompressed8Bit { + if bmp.Header.Stride != uint16(bmp.Header.Width) { + return nil, errStrideNotValid + } + bmp.Pixels, err = provider(bmp.Header.Width, bmp.Header.Height) + if err != nil { + return nil, err + } + err = rle.Decompress(reader, bmp.Pixels) + } else { + bmp.Pixels = make([]byte, int(bmp.Header.Height)*int(bmp.Header.Stride)) + _, err = reader.Read(bmp.Pixels) + } + if err != nil { + return nil, errDataCouldNotBeRead + } + + if bmp.Header.PaletteOffset != 0 { + paletteFlag := uint32(0) + _ = binary.Read(reader, binary.LittleEndian, &paletteFlag) + bmp.Palette = new(Palette) + err = binary.Read(reader, binary.LittleEndian, bmp.Palette) + if err != nil { + return nil, errPaletteCouldNotBeRead + } + } + + return &bmp, nil +} + +// Encode writes the bitmap to a byte array and returns it. +// Compressed bitmaps will be compressed with a reference image with pixel data all 0x00. +func Encode(bmp *Bitmap, offsetBase int) []byte { + rawData := bmp.Pixels + if bmp.Header.Type == TypeCompressed8Bit { + buf := bytes.NewBuffer(nil) + _ = rle.Compress(buf, rawData, nil) + rawData = buf.Bytes() + } + header := bmp.Header + if bmp.Palette != nil { + header.PaletteOffset = int32(offsetBase + HeaderSize + len(rawData)) + } + + buf := bytes.NewBuffer(nil) + _ = binary.Write(buf, binary.LittleEndian, &header) + _ = binary.Write(buf, binary.LittleEndian, rawData) + if bmp.Palette != nil { + _ = binary.Write(buf, binary.LittleEndian, paletteMarker) + _ = binary.Write(buf, binary.LittleEndian, bmp.Palette) + } + + return buf.Bytes() +} diff --git a/modding/hacked/ss1/content/bitmap/Bitmap_test.go b/modding/hacked/ss1/content/bitmap/Bitmap_test.go new file mode 100644 index 0000000..2ce3d98 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Bitmap_test.go @@ -0,0 +1,114 @@ +package bitmap_test + +import ( + "bytes" + "encoding/binary" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeReturnsErrorOnNilSource(t *testing.T) { + _, err := bitmap.Decode(nil) + + assert.Error(t, err, "error expected") +} + +func TestDecodeOfUncompressedDataReturnsBitmap(t *testing.T) { + data := getTestData(bitmap.TypeFlat8Bit, []byte{0xAA}, false) + bmp, err := bitmap.Decode(bytes.NewReader(data)) + + require.Nil(t, err, "no error expected") + require.NotNil(t, bmp, "bitmap expected") + assert.Equal(t, int16(1), bmp.Header.Width) + assert.Equal(t, int16(1), bmp.Header.Height) + assert.Equal(t, []byte{0xAA}, bmp.Pixels) +} + +func TestDecodeOfCompressedDataReturnsBitmap(t *testing.T) { + data := getTestData(bitmap.TypeCompressed8Bit, []byte{0x00, 0x01, 0xBB, 0x80, 0x00, 0x00}, false) + bmp, err := bitmap.Decode(bytes.NewReader(data)) + + require.Nil(t, err, "no error expected") + require.NotNil(t, bmp, "bitmap expected") + assert.Equal(t, int16(1), bmp.Header.Width) + assert.Equal(t, int16(1), bmp.Header.Height) + assert.Equal(t, []byte{0xBB}, bmp.Pixels) +} + +func TestDecodeWithPrivatePaletteReturnsPalette(t *testing.T) { + data := getTestData(bitmap.TypeFlat8Bit, []byte{0xAA}, true) + bmp, err := bitmap.Decode(bytes.NewReader(data)) + + require.Nil(t, err, "no error expected") + require.NotNil(t, bmp, "bitmap expected") + assert.NotNil(t, bmp.Palette, "palette expected") +} + +func TestWriteUncompressedWithoutPalette(t *testing.T) { + sourceData := getTestData(bitmap.TypeFlat8Bit, []byte{0xAA}, false) + bmp, _ := bitmap.Decode(bytes.NewReader(sourceData)) + + result := bitmap.Encode(bmp, 0) + assert.Equal(t, sourceData, result) +} + +func TestWriteCompressedWithoutPalette(t *testing.T) { + sourceData := getTestData(bitmap.TypeCompressed8Bit, []byte{0x01, 0xBB, 0x80, 0x00, 0x00}, false) + bmp, _ := bitmap.Decode(bytes.NewReader(sourceData)) + + result := bitmap.Encode(bmp, 0) + assert.Equal(t, sourceData, result) +} + +func TestWriteUncompressedWithPalette(t *testing.T) { + sourceData := getTestData(bitmap.TypeFlat8Bit, []byte{0xCC}, true) + bmp, _ := bitmap.Decode(bytes.NewReader(sourceData)) + + result := bitmap.Encode(bmp, 0) + assert.Equal(t, sourceData, result) +} + +func TestWriteCompressedWithPalette(t *testing.T) { + sourceData := getTestData(bitmap.TypeCompressed8Bit, []byte{0x01, 0xEE, 0x80, 0x00, 0x00}, true) + bmp, _ := bitmap.Decode(bytes.NewReader(sourceData)) + + result := bitmap.Encode(bmp, 0) + assert.Equal(t, sourceData, result) +} + +func TestWriteWithForceTransparency(t *testing.T) { + sourceData := getTestData(bitmap.TypeFlat8Bit, []byte{0xAA}, false) + sourceData[6] = 1 + bmp, _ := bitmap.Decode(bytes.NewReader(sourceData)) + + result := bitmap.Encode(bmp, 0) + assert.Equal(t, sourceData, result) +} + +func getTestData(bmpType bitmap.Type, data []byte, withPalette bool) []byte { + var header bitmap.Header + buf := bytes.NewBuffer(nil) + + header.Type = bmpType + header.Width = 1 + header.Stride = 1 + header.Height = 1 + header.WidthFactor = 1 + header.HeightFactor = 1 + if withPalette { + header.PaletteOffset = int32(binary.Size(header) + len(data)) + } + _ = binary.Write(buf, binary.LittleEndian, &header) + buf.Write(data) + if withPalette { + var pal bitmap.Palette + _ = binary.Write(buf, binary.LittleEndian, uint32(0x01000000)) + _ = binary.Write(buf, binary.LittleEndian, pal) + } + + return buf.Bytes() +} diff --git a/modding/hacked/ss1/content/bitmap/Bitmapper.go b/modding/hacked/ss1/content/bitmap/Bitmapper.go new file mode 100644 index 0000000..e749c2d --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Bitmapper.go @@ -0,0 +1,105 @@ +package bitmap + +import ( + "image" + "image/color" + "math" +) + +// d64 is the reference white point. +var d65 = [3]float64{0.95047, 1.00000, 1.08883} + +func labF(t float64) float64 { + if t > 6.0/29.0*6.0/29.0*6.0/29.0 { + return math.Cbrt(t) + } + return t/3.0*29.0/6.0*29.0/6.0 + 4.0/29.0 +} + +func square(value float64) float64 { + return value * value +} + +type labEntry struct { + l float64 + a float64 + b float64 +} + +func labEntryFromColor(clr color.Color) labEntry { + rLinear, gLinear, bLinear, _ := clr.RGBA() + r, g, b := float64(rLinear)/float64(0xFFFF), float64(gLinear)/float64(0xFFFF), float64(bLinear)/float64(0xFFFF) + x := 0.4124564*r + 0.3575761*g + 0.1804375*b + y := 0.2126729*r + 0.7151522*g + 0.0721750*b + z := 0.0193339*r + 0.1191920*g + 0.9503041*b + whiteRef := d65 + fy := labF(y / whiteRef[1]) + entry := labEntry{ + l: 1.16*fy - 0.16, + a: 5.0 * (labF(x/whiteRef[0]) - fy), + b: 2.0 * (fy - labF(z/whiteRef[2]))} + + return entry +} + +func (entry labEntry) distanceTo(other labEntry) float64 { + return math.Sqrt(square(entry.l-other.l) + square(entry.a-other.a) + square(entry.b-other.b)) +} + +// Bitmapper creates bitmap images from generic images. +type Bitmapper struct { + pal []labEntry +} + +// NewBitmapper returns a new bitmapper instance based on the given palette. +func NewBitmapper(palette *Palette) *Bitmapper { + bitmapper := &Bitmapper{} + + for _, clr := range palette { + bitmapper.pal = append(bitmapper.pal, labEntryFromColor(clr.Color(0xFF))) + } + + return bitmapper +} + +// Map maps the provided image to a bitmap based on the internal palette. +func (bitmapper *Bitmapper) Map(img image.Image) Bitmap { + var bmp Bitmap + bounds := img.Bounds() + + bmp.Header.Width = int16(math.Max(0, math.Min(float64(bounds.Dx()), math.MaxInt16))) + bmp.Header.Height = int16(math.Max(0, math.Min(float64(bounds.Dy()), math.MaxInt16))) + bmp.Pixels = make([]byte, int(bmp.Header.Width)*int(bmp.Header.Height)) + for row := 0; row < int(bmp.Header.Height); row++ { + for column := 0; column < int(bmp.Header.Width); column++ { + bmp.Pixels[row*int(bmp.Header.Width)+column] = bitmapper.MapColor(img.At(column, row)) + } + } + + return bmp +} + +// MapColor maps the provided color to the nearest index in the palette. +func (bitmapper *Bitmapper) MapColor(clr color.Color) (palIndex byte) { + _, _, _, a := clr.RGBA() // nolint:dogsled + indexWithin := func(index, from, to int) bool { + return (index >= from) && (index <= to) + } + + if a > 0 { + clrEntry := labEntryFromColor(clr) + palDistance := 1000.0 + + for colorIndex, palEntry := range bitmapper.pal { + isRegularColor := indexWithin(colorIndex, 0x01, 0x02) || indexWithin(colorIndex, 0x08, 0x0A) || indexWithin(colorIndex, 0x20, 0xFF) + if isRegularColor { + distance := palEntry.distanceTo(clrEntry) + if distance < palDistance { + palDistance = distance + palIndex = byte(colorIndex) + } + } + } + } + return +} diff --git a/modding/hacked/ss1/content/bitmap/Constants.go b/modding/hacked/ss1/content/bitmap/Constants.go new file mode 100644 index 0000000..034d0a0 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Constants.go @@ -0,0 +1,5 @@ +package bitmap + +const ( + paletteMarker uint32 = 0x01000000 +) diff --git a/modding/hacked/ss1/content/bitmap/Header.go b/modding/hacked/ss1/content/bitmap/Header.go new file mode 100644 index 0000000..b58c8d7 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Header.go @@ -0,0 +1,43 @@ +package bitmap + +// Type describes the data layout of a bitmap. +type Type byte + +// Type constants are listed below. +const ( + // TypeFlat8Bit bitmaps are 8-bit paletted bitmaps that have their pixel stored in a flat layout. + TypeFlat8Bit Type = 2 + // TypeCompressed8Bit bitmaps are 8-bit paletted bitmaps that have their pixel compressed in storage. + // Compression is using run-length-encoding (RLE); See package rle. + TypeCompressed8Bit Type = 4 +) + +// Flag adds further properties. +type Flag uint16 + +// Flag constants are listed below. +const ( + // FlagTransparent is set for bitmaps that shall treat palette index 0x00 as fully transparent. + FlagTransparent Flag = 0x0001 +) + +// HeaderSize is the size of the Header structure, in bytes. +const HeaderSize = 28 + +// Area is a placeholder for either a rectangle, or an anchoring point (first two entries). +type Area [4]int16 + +// Header contains the meta information for a bitmap. +type Header struct { + _ [4]byte + Type Type + _ byte + Flags Flag + Width int16 + Height int16 + Stride uint16 + WidthFactor byte + HeightFactor byte + Area Area + PaletteOffset int32 +} diff --git a/modding/hacked/ss1/content/bitmap/Palette.go b/modding/hacked/ss1/content/bitmap/Palette.go new file mode 100644 index 0000000..8637c97 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/Palette.go @@ -0,0 +1,65 @@ +package bitmap + +import ( + "bytes" + "image/color" + + "github.com/lucasb-eyer/go-colorful" +) + +// RGB is describing Red, Green, and Blue intensities with 8-bit resolution. +type RGB struct { + Red uint8 + Green uint8 + Blue uint8 +} + +// Color returns the RGB data as a regular Color entry. +func (col RGB) Color(alpha byte) color.Color { + return color.RGBA{ + R: col.Red, + G: col.Green, + B: col.Blue, + A: alpha, + } +} + +// PaletteSize indicates how many colors a palette can hold with a byte index. +const PaletteSize = 256 + +// Palette describes a list of colors to be used by bitmaps. +// It is an array of 256 RGB values. +type Palette [PaletteSize]RGB + +// ColorPalette returns a palette usable for the image packages. +func (pal Palette) ColorPalette(firstIndexTransparent bool) color.Palette { + result := make(color.Palette, len(pal)) + for index, col := range pal { + alpha := byte(0xFF) + if (index == 0) && firstIndexTransparent { + alpha = 0x00 + } + result[index] = col.Color(alpha) + } + return result +} + +// IndexClosestTo returns the index into this palette that matches the given color the closest. +// This search excludes the provided indices. +func (pal Palette) IndexClosestTo(rgb RGB, excluding []byte) byte { + targetColor, _ := colorful.MakeColor(rgb.Color(0xFF)) + closestDist := float64(-1) + closestIndex := byte(0) + for index, other := range pal { + if bytes.IndexByte(excluding, byte(index)) >= 0 { + continue + } + otherColor, _ := colorful.MakeColor(other.Color(0xFF)) + dist := targetColor.DistanceLab(otherColor) + if (closestDist < 0) || (dist < closestDist) { + closestDist = dist + closestIndex = byte(index) + } + } + return closestIndex +} diff --git a/modding/hacked/ss1/content/bitmap/PaletteCache.go b/modding/hacked/ss1/content/bitmap/PaletteCache.go new file mode 100644 index 0000000..88bdda4 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/PaletteCache.go @@ -0,0 +1,67 @@ +package bitmap + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// PaletteCache retrieves palettes from a localizer and keeps them decoded until they are invalidated. +type PaletteCache struct { + localizer resource.Localizer + + defaultPalette Palette + palettes map[resource.Key]Palette +} + +// NewPaletteCache returns a new instance. +func NewPaletteCache(localizer resource.Localizer) *PaletteCache { + cache := &PaletteCache{ + localizer: localizer, + palettes: make(map[resource.Key]Palette), + } + for i := 0; i < len(cache.defaultPalette); i++ { + col := &cache.defaultPalette[i] + col.Red = uint8(i) + col.Green = uint8(i) + col.Blue = uint8(i) + } + return cache +} + +// InvalidateResources lets the cache remove any palettes from resources that are specified in the given slice. +func (cache *PaletteCache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key := range cache.palettes { + if key.ID == id { + delete(cache.palettes, key) + } + } + } +} + +// Palette tries to look up given palette. +func (cache *PaletteCache) Palette(key resource.Key) (pal Palette, err error) { + pal, existing := cache.palettes[key] + if existing { + return pal, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + view, err := selector.Select(key.ID) + if err != nil { + return cache.defaultPalette, err + } + if (view.ContentType() != resource.Palette) || (view.BlockCount() != 1) { + return cache.defaultPalette, resource.ErrWrongType(key, resource.Palette) + } + reader, err := view.Block(0) + if err != nil { + return cache.defaultPalette, err + } + decoder := serial.NewDecoder(reader) + decoder.Code(&pal) + if decoder.FirstError() != nil { + return cache.defaultPalette, decoder.FirstError() + } + cache.palettes[key] = pal + return pal, nil +} diff --git a/modding/hacked/ss1/content/bitmap/PaletteCache_test.go b/modding/hacked/ss1/content/bitmap/PaletteCache_test.go new file mode 100644 index 0000000..2c83cc6 --- /dev/null +++ b/modding/hacked/ss1/content/bitmap/PaletteCache_test.go @@ -0,0 +1,169 @@ +package bitmap_test + +import ( + "bytes" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +const ( + basePaletteResourceID = 0x02BC +) + +type PaletteCacheSuite struct { + suite.Suite + + localizedResources resource.LocalizedResourcesList + + instance *bitmap.PaletteCache +} + +func TestPaletteCacheSuite(t *testing.T) { + suite.Run(t, new(PaletteCacheSuite)) +} + +func (suite *PaletteCacheSuite) SetupTest() { + suite.instance = nil +} + +func (suite *PaletteCacheSuite) TestPaletteReturnsValueIfOK() { + pal := suite.somePalette(0) + suite.givenAnInstance() + suite.whenResourcesAre( + suite.someLocalizedResources( + suite.storing(0, pal))) + suite.thenPaletteShouldReturn(pal, 0) +} + +func (suite *PaletteCacheSuite) TestPaletteReturnsErrorIfResourceNotExisting() { + suite.givenAnInstance() + suite.whenResourcesAre() + suite.thenPaletteShouldReturnError(0) +} + +func (suite *PaletteCacheSuite) TestTextTriesToReloadWhenCacheInvalidated() { + suite.givenAnInstance() + suite.givenResourcesAre( + suite.someLocalizedResources( + suite.storing(0, suite.somePalette(0)))) + suite.givenPaletteWasRetrieved(0) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(basePaletteResourceID) + suite.thenPaletteShouldReturnError(0) +} + +func (suite *PaletteCacheSuite) TestInvalidationConsidersOnlyAffectedIDs() { + suite.givenAnInstance() + suite.givenResourcesAre( + suite.someLocalizedResources( + suite.storing(0, suite.somePalette(0)), suite.storing(1, suite.somePalette(1)))) + suite.givenPaletteWasRetrieved(0) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(0x02BD) + suite.thenPaletteShouldReturn(suite.somePalette(0), 0) +} + +func (suite *PaletteCacheSuite) TestPaletteReturnsErrorIfResourceIsNotAPalette() { + suite.givenAnInstance() + suite.whenResourcesAre(suite.someLocalizedResources(suite.storingNonPalette(0))) + suite.thenPaletteShouldReturnError(0) +} + +func (suite *PaletteCacheSuite) givenAnInstance() { + suite.instance = bitmap.NewPaletteCache(suite) +} + +func (suite *PaletteCacheSuite) givenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *PaletteCacheSuite) givenPaletteWasRetrieved(index int) { + _, err := suite.instance.Palette(suite.keyed(index)) + require.Nil(suite.T(), err, "No error expected setting up") +} + +func (suite *PaletteCacheSuite) whenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *PaletteCacheSuite) whenCacheResourcesAreInvalidated(ids ...resource.ID) { + suite.instance.InvalidateResources(ids) +} + +func (suite *PaletteCacheSuite) thenPaletteShouldReturn(expected bitmap.Palette, index int) { + result, err := suite.instance.Palette(suite.keyed(index)) + require.Nil(suite.T(), err, "No error expected") + assert.Equal(suite.T(), expected, result, "Palettes don't match") +} + +func (suite *PaletteCacheSuite) thenPaletteShouldReturnError(index int) { + _, err := suite.instance.Palette(suite.keyed(index)) + require.NotNil(suite.T(), err, "Error expected") +} + +func (suite PaletteCacheSuite) keyed(index int) resource.Key { + return resource.KeyOf(basePaletteResourceID, resource.LangAny, index) +} + +func (suite *PaletteCacheSuite) someLocalizedResources(modifiers ...func(*resource.Store)) resource.LocalizedResources { + var store resource.Store + for _, modifier := range modifiers { + modifier(&store) + } + return resource.LocalizedResources{ + ID: "unnamed", + Language: resource.LangAny, + Viewer: store, + } +} + +func (suite *PaletteCacheSuite) storing(id int, palette bitmap.Palette) func(*resource.Store) { + buf := bytes.NewBuffer(nil) + encoder := serial.NewEncoder(buf) + encoder.Code(palette) + return func(store *resource.Store) { + _ = store.Put(resource.ID(basePaletteResourceID).Plus(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Palette, + Compound: false, + }, + Blocks: resource.BlocksFrom([][]byte{buf.Bytes()}), + }) + } +} + +func (suite *PaletteCacheSuite) somePalette(seed uint8) bitmap.Palette { + var pal bitmap.Palette + for i := 0; i < len(pal); i++ { + pal[i].Red = seed + uint8(i) + pal[i].Green = seed + uint8(255-i) + pal[i].Blue = seed + uint8(i*2) + } + return pal +} + +func (suite *PaletteCacheSuite) storingNonPalette(id int) func(*resource.Store) { + return func(store *resource.Store) { + _ = store.Put(resource.ID(basePaletteResourceID).Plus(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Sound, + Compound: false, + }, + Blocks: resource.BlocksFrom([][]byte{{}}), + }) + } +} + +func (suite *PaletteCacheSuite) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + From: suite.localizedResources, + Lang: lang, + } +} diff --git a/modding/hacked/ss1/content/doc.go b/modding/hacked/ss1/content/doc.go new file mode 100644 index 0000000..d401fcb --- /dev/null +++ b/modding/hacked/ss1/content/doc.go @@ -0,0 +1,4 @@ +/* +Package content and its sub-packages provide resource primitives and their serialization. +*/ +package content diff --git a/modding/hacked/ss1/content/interpreters/Description.go b/modding/hacked/ss1/content/interpreters/Description.go new file mode 100644 index 0000000..89d3b32 --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Description.go @@ -0,0 +1,86 @@ +package interpreters + +// Predicate is a function that specifies whether a certain description +// is active within another. +type Predicate func(inst *Instance) bool + +// Always is a predicate that returns true for every call. +func Always(inst *Instance) bool { + return true +} + +// Never is a predicate that returns false for every call. +func Never(inst *Instance) bool { + return false +} + +// Description is the meta information about a data interpreter. A description +// is an immutable object that provides new descriptions with any modification. +type Description struct { + fields map[string]*entry + refinements map[string]*refinement + lastField *entry +} + +var empty = newDescription() + +// New returns an empty description. +func New() *Description { + return empty +} + +func newDescription() *Description { + return &Description{ + fields: make(map[string]*entry), + refinements: make(map[string]*refinement)} +} + +func (desc *Description) clone() *Description { + cloned := newDescription() + + for key, e := range desc.fields { + cloned.fields[key] = e + } + for key, r := range desc.refinements { + cloned.refinements[key] = r + } + + return cloned +} + +// For returns an instance of an interpreter with the given data slice +// as backing buffer. +func (desc *Description) For(data []byte) *Instance { + return &Instance{desc: desc, data: data} +} + +// With extends the given description with a new field. +// The returned description is a new, separated object from the originating one. +func (desc *Description) With(key string, byteStart int, byteCount int) *Description { + cloned := desc.clone() + cloned.lastField = &entry{start: byteStart, count: byteCount} + cloned.fields[key] = cloned.lastField + + return cloned +} + +// As sets the value range for the previously added field. +func (desc *Description) As(fieldRange FieldRange) *Description { + if desc.lastField == nil { + panic("No field active") + } + desc.lastField.via = fieldRange + return desc +} + +// Refining adds another description within the given one. The provided predicate +// determines whether the refined description is active. +func (desc *Description) Refining(key string, byteStart int, byteCount int, refined *Description, predicate Predicate) *Description { + cloned := desc.clone() + cloned.refinements[key] = &refinement{ + entry: entry{start: byteStart, count: byteCount}, + desc: refined, + predicate: predicate} + + return cloned +} diff --git a/modding/hacked/ss1/content/interpreters/Description_test.go b/modding/hacked/ss1/content/interpreters/Description_test.go new file mode 100644 index 0000000..e179f58 --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Description_test.go @@ -0,0 +1,87 @@ +package interpreters_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/interpreters" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDescriptionWithReturnsANewDescription(t *testing.T) { + second := interpreters.New().With("test", 0, 32) + + require.NotNil(t, second) + assert.NotEqual(t, interpreters.New(), second) +} + +func TestDescriptionWithCopiesPreviousFields(t *testing.T) { + first := interpreters.New().With("field1", 0, 32) + second := first.With("field2", 32, 32) + onlyField2 := interpreters.New().With("field2", 32, 32) + + assert.NotEqual(t, onlyField2, second) +} + +func TestDescriptionWithLeavesOriginalAlone(t *testing.T) { + first := interpreters.New().With("field1", 0, 32) + onlyField1 := interpreters.New().With("field1", 0, 32) + + first.With("field2", 32, 32) + + assert.Equal(t, onlyField1, first) +} + +func TestDescriptionForCreatesNewInstance(t *testing.T) { + data := make([]byte, 10) + inst := interpreters.New().For(data) + + assert.NotNil(t, inst) +} + +func TestDescriptionRefiningReturnsANewDescription(t *testing.T) { + refined := interpreters.New().With("field1", 0, 16) + second := interpreters.New().Refining("test", 0, 4, refined, interpreters.Always) + + require.NotNil(t, second) + assert.NotEqual(t, interpreters.New(), second) +} + +func TestDescriptionRefiningCopiesPreviousFields(t *testing.T) { + first := interpreters.New().With("fieldA", 0, 8) + refined := interpreters.New().With("field1", 0, 16) + second := first.Refining("test", 0, 4, refined, interpreters.Always) + secondMissing := interpreters.New().Refining("test", 0, 4, refined, interpreters.Always) + + assert.NotEqual(t, secondMissing, second) +} + +func TestDescriptionRefiningCopiesPreviousRefinements(t *testing.T) { + first := interpreters.New().Refining("sub1", 0, 1, interpreters.New(), interpreters.Always) + second := first.Refining("sub2", 0, 1, interpreters.New(), interpreters.Always) + + assert.NotContains(t, first.For(nil).ActiveRefinements(), "sub2") + assert.Contains(t, second.For(nil).ActiveRefinements(), "sub1") +} + +func TestDescriptionAsPanicsIfNoFieldIsActive(t *testing.T) { + assert.PanicsWithValue(t, "No field active", func() { + interpreters.New().As(func(simpl *interpreters.Simplifier) bool { return false }) + }) +} + +func TestDescriptionAsRegistersRangeFunctionForLastField(t *testing.T) { + rangeFuncCalled := false + rangeFunc := func(simpl *interpreters.Simplifier) bool { + rangeFuncCalled = true + return true + } + desc := interpreters.New().With("fieldA", 0, 2).As(rangeFunc) + + inst := desc.For([]byte{0x00, 0x01}) + simplifier := interpreters.NewSimplifier(func(minValue, maxValue int64, formatter interpreters.RawValueFormatter) {}) + inst.Describe("fieldA", simplifier) + + assert.True(t, rangeFuncCalled) +} diff --git a/modding/hacked/ss1/content/interpreters/Entry.go b/modding/hacked/ss1/content/interpreters/Entry.go new file mode 100644 index 0000000..4b703e4 --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Entry.go @@ -0,0 +1,16 @@ +package interpreters + +// FieldRange is a function specializing the range of a field to a simplifier. +type FieldRange func(*Simplifier) bool + +type entry struct { + start int + count int + via FieldRange +} + +func (e *entry) describe(simplifier *Simplifier) { + if (e.via == nil) || !e.via(simplifier) { + simplifier.rawValue(e) + } +} diff --git a/modding/hacked/ss1/content/interpreters/Instance.go b/modding/hacked/ss1/content/interpreters/Instance.go new file mode 100644 index 0000000..27b4d91 --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Instance.go @@ -0,0 +1,138 @@ +package interpreters + +import ( + "bytes" + "sort" +) + +// Instance is one instantiated interpreter for a data block, +// based on a predetermined description. +type Instance struct { + desc *Description + data []byte +} + +// Raw returns the raw array. +func (inst *Instance) Raw() []byte { + return inst.data +} + +// Undefined returns a byte array that specifies which bits were not +// specified. The array has the same length as the underlying buffer and +// has all bits set to 1 where no field or active refinement is present. +func (inst *Instance) Undefined() []byte { + mask := bytes.Repeat([]byte{0xFF}, len(inst.data)) + + for _, e := range inst.desc.fields { + if inst.isValidRange(e) { + copy(mask[e.start:e.start+e.count], bytes.Repeat([]byte{0x00}, e.count)) + } + } + for key, r := range inst.desc.refinements { + if r.predicate(inst) { + refined := inst.Refined(key) + subMask := refined.Undefined() + + for index, m := range subMask { + mask[r.start+index] &= m + } + } + } + + return mask +} + +// Keys returns an array of all registered keys, sorted by start index. +func (inst *Instance) Keys() []string { + return sortKeys(inst.desc.fields) +} + +// ActiveRefinements returns an array of all keys where the corresponding refinement +// is active (The corresponding predicate returns true). +func (inst *Instance) ActiveRefinements() (keys []string) { + entries := make(map[string]*entry) + for key, r := range inst.desc.refinements { + entries[key] = &r.entry + } + sortedKeys := sortKeys(entries) + for _, key := range sortedKeys { + if inst.desc.refinements[key].predicate(inst) { + keys = append(keys, key) + } + } + + return +} + +// Get returns the value associated with the given key. Should there be no +// value for the requested key, the function returns 0. +func (inst *Instance) Get(key string) uint32 { + e := inst.desc.fields[key] + value := uint32(0) + + if e != nil && inst.isValidRange(e) { + for i := 0; i < e.count; i++ { + value = (value << 8) | uint32(inst.data[e.start+e.count-1-i]) + } + } + + return value +} + +// Describe returns the description of a value key. +func (inst *Instance) Describe(key string, simplifier *Simplifier) { + e := inst.desc.fields[key] + + if e != nil && inst.isValidRange(e) { + e.describe(simplifier) + } +} + +// Set stores the provided value with the given key. Should there be no +// registration for the key, the function does nothing. +func (inst *Instance) Set(key string, value uint32) { + e := inst.desc.fields[key] + + if e != nil && inst.isValidRange(e) { + for i := 0; i < e.count; i++ { + inst.data[e.start+i] = byte(value >> uint32(i*8)) + } + } +} + +// Refined returns an instance that was nested according to the description. +// This method returns an instance for registered refinements even if their predicate +// would not specify them being active. +// Should the refinement not exist, an instance without any fields will be returned. +func (inst *Instance) Refined(key string) (refined *Instance) { + r := inst.desc.refinements[key] + + if r != nil && inst.isValidRange(&r.entry) { + refined = &Instance{ + desc: r.desc, + data: inst.data[r.start : r.start+r.count]} + } else { + refined = New().For(nil) + } + + return +} + +func (inst *Instance) isValidRange(e *entry) bool { + available := len(inst.data) + + return (e.start + e.count) <= available +} + +func sortKeys(entries map[string]*entry) []string { + keys := make([]string, 0, len(entries)) + for key := range entries { + keys = append(keys, key) + } + sort.Slice(keys, func(a, b int) bool { + entryA := entries[keys[a]] + entryB := entries[keys[b]] + return entryA.start < entryB.start + }) + return keys +} diff --git a/modding/hacked/ss1/content/interpreters/Instance_test.go b/modding/hacked/ss1/content/interpreters/Instance_test.go new file mode 100644 index 0000000..a3643b0 --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Instance_test.go @@ -0,0 +1,146 @@ +package interpreters_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/interpreters" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type InstanceSuite struct { + suite.Suite + + data []byte + inst *interpreters.Instance +} + +func TestInstanceSuite(t *testing.T) { + suite.Run(t, new(InstanceSuite)) +} + +func (suite *InstanceSuite) SetupTest() { + sub1 := interpreters.New(). + With("subField0", 0, 1). + With("subField1", 1, 2) + + sub2 := interpreters.New(). + With("subFieldA", 0, 2). + With("subFieldB", 2, 1) + + desc := interpreters.New(). + With("field0", 0, 1). + With("field1", 1, 1). + With("field2", 2, 2). + With("field3", 4, 4). + With("misaligned", 9, 2). + With("beyond", 256, 16). + Refining("sub1", 3, 3, sub1, interpreters.Always). + Refining("sub2", 6, 3, sub2, func(inst *interpreters.Instance) bool { return inst.Get("field0") == 0 }). + Refining("sub3", 7, 1, interpreters.New(), interpreters.Always) + + suite.data = []byte{0x01, 0x5A, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A} + suite.inst = desc.For(suite.data) +} + +func (suite *InstanceSuite) TestGetReturnsZeroForUnknownKey() { + result := suite.inst.Get("unknown") + + assert.Equal(suite.T(), uint32(0), result) +} + +func (suite *InstanceSuite) TestGetReturnsValueLittleEndian() { + result := suite.inst.Get("field2") + + assert.Equal(suite.T(), uint32(0x0403), result) +} + +func (suite *InstanceSuite) TestGetReturnsZeroForKeyBeyondSize() { + result := suite.inst.Get("beyond") + + assert.Equal(suite.T(), uint32(0), result) +} + +func (suite *InstanceSuite) TestSetIgnoresMisalignedFields() { + suite.inst.Set("misaligned", 0xEEFF) + + assert.Equal(suite.T(), []byte{0x09, 0x0A}, suite.data[8:10]) +} + +func (suite *InstanceSuite) TestSetStoresValue() { + suite.inst.Set("field3", 0xAABBCCDD) + + assert.Equal(suite.T(), []byte{0xDD, 0xCC, 0xBB, 0xAA}, suite.data[4:8]) +} + +func (suite *InstanceSuite) TestRefinedForUnknownKeyReturnsNullObject() { + refined := suite.inst.Refined("unknown") + + require.NotNil(suite.T(), refined) + assert.Equal(suite.T(), uint32(0), refined.Get("something")) +} + +func (suite *InstanceSuite) TestRefinedReturnsInstanceForSubsection() { + refined := suite.inst.Refined("sub1") + + assert.Equal(suite.T(), uint32(0x0605), refined.Get("subField1")) +} + +func (suite *InstanceSuite) TestRefinedAllowsModificationOfOriginalData() { + refined := suite.inst.Refined("sub1") + refined.Set("subField0", 0xAB) + + assert.Equal(suite.T(), byte(0xAB), suite.data[3]) +} + +func (suite *InstanceSuite) TestRefinedReturnsInstanceEvenIfNotActive() { + refined := suite.inst.Refined("sub2") + + assert.Equal(suite.T(), uint32(0x0807), refined.Get("subFieldA")) +} + +func (suite *InstanceSuite) TestKeysReturnsListOfKeysSortedByStartIndex() { + keys := suite.inst.Keys() + + assert.Equal(suite.T(), []string{"field0", "field1", "field2", "field3", "misaligned", "beyond"}, keys) +} + +func (suite *InstanceSuite) TestActiveRefinementsReturnsListOfActiveKeysSortedByStartIndex() { + keys := suite.inst.ActiveRefinements() + + assert.Equal(suite.T(), []string{"sub1", "sub3"}, keys) +} + +func (suite *InstanceSuite) TestActiveRefinementsCanChange() { + suite.inst.Set("field0", 0) + keys := suite.inst.ActiveRefinements() + + assert.Equal(suite.T(), []string{"sub1", "sub2", "sub3"}, keys) +} + +func (suite *InstanceSuite) TestRawReturnsData() { + data := suite.inst.Refined("sub2").Raw() + + assert.Equal(suite.T(), []byte{0x07, 0x08, 0x09}, data) +} + +func (suite *InstanceSuite) TestRawAllowsModificationOfOriginalData() { + suite.inst.Refined("sub2").Raw()[1] = 0xEF + + assert.Equal(suite.T(), byte(0xEF), suite.data[7]) +} + +func (suite *InstanceSuite) TestUndefinedReturnsUndefinedBits() { + data := suite.inst.Undefined() + + assert.Equal(suite.T(), []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF}, data) +} + +func (suite *InstanceSuite) TestUndefinedConsidersActiveRefinements() { + suite.inst.Set("field0", 0) + data := suite.inst.Undefined() + + assert.Equal(suite.T(), []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF}, data) +} diff --git a/modding/hacked/ss1/content/interpreters/Refinement.go b/modding/hacked/ss1/content/interpreters/Refinement.go new file mode 100644 index 0000000..9f930f9 --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Refinement.go @@ -0,0 +1,8 @@ +package interpreters + +type refinement struct { + entry + + desc *Description + predicate Predicate +} diff --git a/modding/hacked/ss1/content/interpreters/Simplifier.go b/modding/hacked/ss1/content/interpreters/Simplifier.go new file mode 100644 index 0000000..7ab00ad --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Simplifier.go @@ -0,0 +1,193 @@ +package interpreters + +import ( + "math" +) + +// RawValueFormatter provides the format function for given value. +// If nothing special is to be displayed, an empty string should be returned. +// The handler is responsible for adding the number value representation. +type RawValueFormatter func(value int) string + +// RawValueHandler is for a simple value range. +type RawValueHandler func(minValue, maxValue int64, formatter RawValueFormatter) + +// EnumValueHandler is for enumerated (mapped) values. +type EnumValueHandler func(values map[uint32]string) + +// BitfieldHandler is for bitfields. +type BitfieldHandler func(values map[uint32]string) + +// ObjectIDHandler is for object identifier in a level. +type ObjectIDHandler func() + +// SpecialHandler is for rare occasions. +type SpecialHandler func() + +// RotationHandler is for circular values. +type RotationHandler func(minValue, maxValue int64) + +func basicToString(value int) string { + return "" +} + +// RangedValue creates a field range for specific minimum and maximum values. +func RangedValue(minValue, maxValue int64) FieldRange { + return FormattedRangedValue(minValue, maxValue, basicToString) +} + +// FormattedRangedValue is similar to RangedValue, and adds a formatting function. +func FormattedRangedValue(minValue, maxValue int64, formatter RawValueFormatter) FieldRange { + return func(simpl *Simplifier) bool { + return simpl.rangedValue(minValue, maxValue, formatter) + } +} + +// EnumValue creates a field range describing enumerated values. +func EnumValue(values map[uint32]string) FieldRange { + return func(simpl *Simplifier) bool { + return simpl.enumValue(values) + } +} + +// Bitfield creates a field range describing bitfield values. +func Bitfield(values map[uint32]string) FieldRange { + return func(simpl *Simplifier) bool { + return simpl.bitfield(values) + } +} + +// ObjectID creates a field range describing object identifier. +func ObjectID() FieldRange { + return func(simpl *Simplifier) bool { + return simpl.objectIndex() + } +} + +// RotationValue creates a field range for a rotational property. +func RotationValue(minValue, maxValue int64) FieldRange { + return func(simpl *Simplifier) bool { + return simpl.rotation(minValue, maxValue) + } +} + +// SpecialValue creates a field range for special fields. +// Currently known special values: +// * BinaryCodedDecimal - for keypads storing their number as BCD +// * LevelTexture - index value into level texture list +// * MaterialOrLevelTexture - index value into level texture list, or material (bit 7 toggles) +// * VariableKey - for actions +// * VariableCondition - for action conditions +// * ObjectTriple - for 0x00CCSSTT selection +// * ObjectHeight - for level height value 0..255 +// * MoveTileHeight - for change tile height action +// * TileHeight - tile heights +// * TileType - tile types +// * Unknown - It is unclear whether this field would have any effect, none identified so far +// * Ignored - Although values have been found in this field, they don't appear to have any effect +// * Mistake - It is assumed that these values should have been placed somewhere else. Typical example: Container content +// * Internal - Fields that are handled mainly by the engine and shouldn't be modified. +func SpecialValue(specialType string) FieldRange { + return func(simpl *Simplifier) bool { + return simpl.specialValue(specialType) + } +} + +// Simplifier forwards descriptions in a way the requester can use. +type Simplifier struct { + rawValueHandler RawValueHandler + enumValueHandler EnumValueHandler + bitfieldHandler BitfieldHandler + objectIDHandler ObjectIDHandler + rotationHandler RotationHandler + specialHandler map[string]SpecialHandler +} + +// NewSimplifier returns a new instance of a simplifier, with the minimal +// handler set. +func NewSimplifier(rawValueHandler RawValueHandler) *Simplifier { + return &Simplifier{ + rawValueHandler: rawValueHandler, + specialHandler: make(map[string]SpecialHandler)} +} + +func (simpl *Simplifier) rawValue(e *entry) { + max := int64(math.Pow(2, float64(e.count*8))) + if max == 256 { + simpl.rawValueHandler(0, 255, basicToString) + } else { + half := max / 2 + simpl.rawValueHandler(-1, half-1, basicToString) + } +} + +func (simpl *Simplifier) rangedValue(minValue, maxValue int64, formatter RawValueFormatter) bool { + simpl.rawValueHandler(minValue, maxValue, formatter) + return true +} + +// SetEnumValueHandler registers the handler for enumerations. +func (simpl *Simplifier) SetEnumValueHandler(handler EnumValueHandler) { + simpl.enumValueHandler = handler +} + +func (simpl *Simplifier) enumValue(values map[uint32]string) (result bool) { + if simpl.enumValueHandler != nil { + simpl.enumValueHandler(values) + result = true + } + return +} + +// SetBitfieldHandler registers the handler for bitfields. +func (simpl *Simplifier) SetBitfieldHandler(handler BitfieldHandler) { + simpl.bitfieldHandler = handler +} + +func (simpl *Simplifier) bitfield(values map[uint32]string) (result bool) { + if simpl.bitfieldHandler != nil { + simpl.bitfieldHandler(values) + result = true + } + return +} + +// SetObjectIDHandler registers the handler for object indices. +func (simpl *Simplifier) SetObjectIDHandler(handler ObjectIDHandler) { + simpl.objectIDHandler = handler +} + +func (simpl *Simplifier) objectIndex() (result bool) { + if simpl.objectIDHandler != nil { + simpl.objectIDHandler() + result = true + } + return +} + +// SetSpecialHandler registers the handler for special values. +func (simpl *Simplifier) SetSpecialHandler(specialType string, handler SpecialHandler) { + simpl.specialHandler[specialType] = handler +} + +func (simpl *Simplifier) specialValue(specialType string) (result bool) { + handler, existing := simpl.specialHandler[specialType] + if existing && (handler != nil) { + handler() + result = true + } + return +} + +// SetRotationHandler registers the handler for rotation values. +func (simpl *Simplifier) SetRotationHandler(handler RotationHandler) { + simpl.rotationHandler = handler +} + +func (simpl *Simplifier) rotation(minValue, maxValue int64) (result bool) { + if simpl.rotationHandler != nil { + simpl.rotationHandler(minValue, maxValue) + result = true + } + return +} diff --git a/modding/hacked/ss1/content/interpreters/Simplifier_test.go b/modding/hacked/ss1/content/interpreters/Simplifier_test.go new file mode 100644 index 0000000..f3d3464 --- /dev/null +++ b/modding/hacked/ss1/content/interpreters/Simplifier_test.go @@ -0,0 +1,42 @@ +package interpreters // nolint: testpackage + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSimplifierRawValueCallsHandler(t *testing.T) { + var calledMinValue int64 + var calledMaxValue int64 + rawHandler := func(minValue, maxValue int64, formatter RawValueFormatter) { + calledMinValue, calledMaxValue = minValue, maxValue + } + simpl := NewSimplifier(rawHandler) + + simpl.rawValue(&entry{count: 2}) + + assert.Equal(t, int64(-1), calledMinValue) + assert.Equal(t, int64(32767), calledMaxValue) +} + +func TestSimplifierEnumValueReturnsFalseIfNoHandlerRegistered(t *testing.T) { + simpl := NewSimplifier(func(minValue, maxValue int64, formatter RawValueFormatter) {}) + + result := simpl.enumValue(map[uint32]string{}) + + assert.False(t, result) +} + +func TestSimplifierEnumValueCallsRegisteredHandler(t *testing.T) { + result := map[uint32]string{} + simpl := NewSimplifier(func(minValue, maxValue int64, formatter RawValueFormatter) {}) + simpl.SetEnumValueHandler(func(values map[uint32]string) { + result = values + }) + + expected := map[uint32]string{1: "value"} + simpl.enumValue(expected) + + assert.Equal(t, expected, result) +} diff --git a/modding/hacked/ss1/content/movie/Audio.go b/modding/hacked/ss1/content/movie/Audio.go new file mode 100644 index 0000000..710e229 --- /dev/null +++ b/modding/hacked/ss1/content/movie/Audio.go @@ -0,0 +1,53 @@ +package movie + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" +) + +const audioEntrySize = 0x2000 + +// Audio represents the sound part of a movie. +type Audio struct { + Sound audio.L8 +} + +func (a Audio) duration() format.Timestamp { + if a.Sound.SampleRate <= 0 { + return format.Timestamp{} + } + return format.TimestampFromSeconds(a.Sound.Duration()) +} + +func (a Audio) encode() []format.EntryBucket { + buckets := make([]format.EntryBucket, 0, (len(a.Sound.Samples)/audioEntrySize)+1) + addBucket := func(ts format.Timestamp, samples []byte) { + buckets = append(buckets, + format.EntryBucket{ + Priority: format.EntryBucketPriorityAudio, + Timestamp: ts, + Entries: []format.Entry{ + { + Timestamp: ts, + Data: format.AudioEntryData{ + Samples: samples, + }, + }, + }, + }) + } + + startOffset := 0 + for (startOffset + audioEntrySize) <= len(a.Sound.Samples) { + ts := format.TimestampFromSeconds(float32(startOffset) / a.Sound.SampleRate) + endOffset := startOffset + audioEntrySize + addBucket(ts, a.Sound.Samples[startOffset:endOffset]) + startOffset = endOffset + } + if startOffset < len(a.Sound.Samples) { + ts := format.TimestampFromSeconds(float32(startOffset) / a.Sound.SampleRate) + addBucket(ts, a.Sound.Samples[startOffset:]) + } + + return buckets +} diff --git a/modding/hacked/ss1/content/movie/Cache.go b/modding/hacked/ss1/content/movie/Cache.go new file mode 100644 index 0000000..a10bc72 --- /dev/null +++ b/modding/hacked/ss1/content/movie/Cache.go @@ -0,0 +1,132 @@ +package movie + +import ( + "bytes" + "io/ioutil" + + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" +) + +const sizeLimit = 0xFFFFFF + +// Cache retrieves movie container from a localizer and keeps them decoded until they are invalidated. +type Cache struct { + cp text.Codepage + + localizer resource.Localizer + + movies map[resource.Key]*cachedMovie +} + +type cachedMovie struct { + sizeWarning bool + container Container + scenes []Scene +} + +func (cached *cachedMovie) video() []Scene { + if len(cached.scenes) > 0 { + return cached.scenes + } + cached.scenes, _ = cached.container.Video.Decompress() + return cached.scenes +} + +// NewCache returns a new instance. +func NewCache(cp text.Codepage, localizer resource.Localizer) *Cache { + cache := &Cache{ + cp: cp, + localizer: localizer, + + movies: make(map[resource.Key]*cachedMovie), + } + return cache +} + +// InvalidateResources lets the cache remove any movies from resources that are specified in the given slice. +func (cache *Cache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key := range cache.movies { + if key.ID == id { + delete(cache.movies, key) + } + } + } +} + +func (cache *Cache) cached(key resource.Key) (*cachedMovie, error) { + value, existing := cache.movies[key] + if existing { + return value, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + view, err := selector.Select(key.ID.Plus(key.Index)) + if err != nil { + return nil, err + } + if (view.ContentType() != resource.Movie) || view.Compound() || (view.BlockCount() != 1) { + return nil, resource.ErrWrongType(key, resource.Movie) + } + reader, err := view.Block(0) + if err != nil { + return nil, err + } + data, err := ioutil.ReadAll(reader) + if err != nil { + return nil, err + } + container, err := Read(bytes.NewReader(data), cache.cp) + if err != nil { + return nil, err + } + cached := &cachedMovie{ + sizeWarning: len(data) > sizeLimit, + container: container, + } + cache.movies[key] = cached + return cached, nil +} + +// SizeWarning returns true if the given movie is cached and is larger than the underlying archive would support. +func (cache *Cache) SizeWarning(key resource.Key) bool { + cached, existing := cache.movies[key] + return existing && cached.sizeWarning +} + +// Container retrieves and caches the underlying movie, and returns the complete container. +func (cache *Cache) Container(key resource.Key) (Container, error) { + cached, err := cache.cached(key) + if err != nil { + return Container{}, err + } + return cached.container, nil +} + +// Audio retrieves and caches the underlying movie, and returns the audio only. +func (cache *Cache) Audio(key resource.Key) (sound audio.L8, err error) { + cached, err := cache.cached(key) + if err != nil { + return + } + return cached.container.Audio.Sound, nil +} + +// Video retrieves and caches the underlying movie, and returns the complete list of decoded scenes. +func (cache *Cache) Video(key resource.Key) ([]Scene, error) { + cached, err := cache.cached(key) + if err != nil { + return nil, err + } + return cached.video(), nil +} + +// Subtitles retrieves and caches the underlying movie, and returns the subtitles for given language. +func (cache *Cache) Subtitles(key resource.Key, language resource.Language) (SubtitleList, error) { + cached, err := cache.cached(key) + if err != nil { + return SubtitleList{}, err + } + return cached.container.Subtitles.PerLanguage[language], nil +} diff --git a/modding/hacked/ss1/content/movie/ContainSoundData.go b/modding/hacked/ss1/content/movie/ContainSoundData.go new file mode 100644 index 0000000..f8885e1 --- /dev/null +++ b/modding/hacked/ss1/content/movie/ContainSoundData.go @@ -0,0 +1,18 @@ +package movie + +import ( + "bytes" + + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/text" +) + +// ContainSoundData packs a sound data into a container and encodes it. +func ContainSoundData(soundData audio.L8) []byte { + var container Container + container.Audio.Sound = soundData + + buffer := bytes.NewBuffer(nil) + _ = Write(buffer, container, text.DefaultCodepage()) + return buffer.Bytes() +} diff --git a/modding/hacked/ss1/content/movie/Container.go b/modding/hacked/ss1/content/movie/Container.go new file mode 100644 index 0000000..fff7921 --- /dev/null +++ b/modding/hacked/ss1/content/movie/Container.go @@ -0,0 +1,20 @@ +package movie + +import "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" + +// Container wraps the information and data of a MOVI container. +type Container struct { + Audio Audio + Video Video + Subtitles Subtitles +} + +func (container Container) duration() format.Timestamp { + var max format.Timestamp + for _, ts := range []format.Timestamp{container.Audio.duration(), container.Video.duration(), container.Subtitles.duration()} { + if max.IsBefore(ts) { + max = ts + } + } + return max +} diff --git a/modding/hacked/ss1/content/movie/Read.go b/modding/hacked/ss1/content/movie/Read.go new file mode 100644 index 0000000..7105a97 --- /dev/null +++ b/modding/hacked/ss1/content/movie/Read.go @@ -0,0 +1,211 @@ +package movie + +import ( + "bytes" + "encoding/binary" + "io" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial" +) + +const ( + errSourceIsNil ss1.StringError = "source is nil" + errInvalidFormat ss1.StringError = "not a MOVI format" + errLowResVideoNotSupported ss1.StringError = "low-res video not supported" +) + +// Read tries to extract a MOVI container from the provided reader. +// On success the position of the reader is past the last data entry. +// On failure the position of the reader is undefined. +func Read(source io.ReadSeeker, cp text.Codepage) (Container, error) { + if source == nil { + return Container{}, errSourceIsNil + } + + var header format.Header + startPos, _ := source.Seek(0, io.SeekCurrent) + var container Container + err := binary.Read(source, binary.LittleEndian, &header) + if err != nil { + return Container{}, err + } + err = verifyAndExtractHeader(&header, &container) + if err != nil { + return Container{}, err + } + var startPalette bitmap.Palette + err = readPalette(source, &startPalette) + if err != nil { + return Container{}, err + } + entries, err := readIndexAndEntries(source, startPos, &header) + if err != nil { + return Container{}, err + } + err = parseEntries(entries, &container, + cp, startPalette, format.Timestamp{Second: byte(header.Duration.Number), Fraction: header.Duration.Fraction}) + if err != nil { + return Container{}, err + } + + return container, nil +} + +func verifyAndExtractHeader(header *format.Header, container *Container) error { + if !bytes.Equal(header.Tag[:], bytes.NewBufferString(format.Tag).Bytes()) { + return errInvalidFormat + } + + container.Video.Height = header.VideoHeight + container.Video.Width = header.VideoWidth + container.Audio.Sound.SampleRate = header.AudioSampleRate.ToFloat() + return nil +} + +func readPalette(source io.Reader, pal *bitmap.Palette) error { + decoder := serial.NewDecoder(source) + decoder.Code(pal) + return decoder.FirstError() +} + +func readIndexAndEntries(source io.ReadSeeker, startPos int64, header *format.Header) ([]format.Entry, error) { + entries := make([]format.Entry, 0, header.IndexEntryCount) + indexEntries := make([]format.IndexTableEntry, header.IndexEntryCount) + err := binary.Read(source, binary.LittleEndian, indexEntries) + if err != nil { + return nil, err + } + for index, indexEntry := range indexEntries { + entryType := format.DataType(indexEntry.Type) + + if entryType != format.DataTypeEndOfMedia { + timestamp := format.Timestamp{ + Second: indexEntry.TimestampSecond, + Fraction: indexEntry.TimestampFraction, + } + length := int(indexEntries[index+1].DataOffset - indexEntry.DataOffset) + + _, err = source.Seek(startPos+int64(indexEntry.DataOffset), io.SeekStart) + if err != nil { + return nil, err + } + + entryData, err := readEntry(entryType, source, length) + if err != nil { + return nil, err + } + entries = append(entries, format.Entry{ + Timestamp: timestamp, + Data: entryData, + }) + } + } + return entries, nil +} + +func readEntry(entryType format.DataType, source io.Reader, dataSize int) (format.EntryData, error) { + limited := io.LimitReader(source, int64(dataSize)) + switch entryType { + case format.DataTypeLowResVideo: + return format.LowResVideoEntryFrom(limited, dataSize) + case format.DataTypeHighResVideo: + return format.HighResVideoEntryFrom(limited, dataSize) + case format.DataTypeAudio: + return format.AudioEntryFrom(limited, dataSize) + case format.DataTypeSubtitle: + return format.SubtitleEntryFrom(limited, dataSize) + case format.DataTypePalette: + return format.PaletteEntryFrom(limited) + case format.DataTypePaletteReset: + return format.PaletteResetEntryFrom() + case format.DataTypePaletteLookupList: + return format.PaletteLookupEntryFrom(limited, dataSize) + case format.DataTypeControlDictionary: + return format.ControlDictionaryEntryFrom(limited, dataSize) + default: + return format.UnknownEntryFrom(entryType, limited, dataSize) + } +} + +func parseEntries(entries []format.Entry, container *Container, + cp text.Codepage, startPalette bitmap.Palette, endTimestamp format.Timestamp) error { + palette := startPalette + var paletteLookup []byte + var controlDictionary []compression.ControlWord + var highResScene *HighResScene + sceneChanging := true + finishScene := func() { + if highResScene != nil { + container.Video.Scenes = append(container.Video.Scenes, *highResScene) + } + highResScene = nil + } + var frame *HighResFrame + finishFrame := func(timestamp format.Timestamp) { + if frame != nil { + frame.displayTime = timestamp.Minus(frame.displayTime) + highResScene.frames = append(highResScene.frames, *frame) + } + frame = nil + } + for _, entry := range entries { + switch data := entry.Data.(type) { + case format.AudioEntryData: + container.Audio.Sound.Samples = append(container.Audio.Sound.Samples, data.Samples...) + case format.SubtitleEntryData: + subText := cp.Decode(data.Text) + switch data.Control { + case format.SubtitleTextStd: + container.Subtitles.add(resource.LangDefault, entry.Timestamp.ToDuration(), subText) + case format.SubtitleTextGer: + container.Subtitles.add(resource.LangGerman, entry.Timestamp.ToDuration(), subText) + case format.SubtitleTextFrn: + container.Subtitles.add(resource.LangFrench, entry.Timestamp.ToDuration(), subText) + case format.SubtitleArea: + default: + } + case format.LowResVideoEntryData: + // ignored for now + return errLowResVideoNotSupported + case format.PaletteLookupEntryData: + sceneChanging = true + paletteLookup = data.List + case format.ControlDictionaryEntryData: + sceneChanging = true + controlDictionary = data.Words + case format.PaletteResetEntryData: + sceneChanging = true + palette = bitmap.Palette{} + case format.PaletteEntryData: + sceneChanging = true + palette = data.Colors + case format.HighResVideoEntryData: + finishFrame(entry.Timestamp) + if sceneChanging { + finishScene() + highResScene = &HighResScene{ + palette: palette, + paletteLookup: paletteLookup, + controlWords: controlDictionary, + } + sceneChanging = false + } + frame = &HighResFrame{ + bitstream: data.Bitstream, + maskstream: data.Maskstream, + displayTime: entry.Timestamp, + } + default: + } + } + finishFrame(endTimestamp) + finishScene() + + return nil +} diff --git a/modding/hacked/ss1/content/movie/Read_test.go b/modding/hacked/ss1/content/movie/Read_test.go new file mode 100644 index 0000000..bcff65f --- /dev/null +++ b/modding/hacked/ss1/content/movie/Read_test.go @@ -0,0 +1,87 @@ +package movie_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" + "github.com/inkyblackness/hacked/ss1/content/text" +) + +func TestReadReturnsErrorOnNil(t *testing.T) { + _, err := movie.Read(nil, text.DefaultCodepage()) + + assert.Errorf(t, err, "source is nil") +} + +func TestReadReturnsContainerOnEmptyFile(t *testing.T) { + buffer := bytes.NewBufferString(format.Tag) + buffer.Write(make([]byte, 0x100+0x300-len(format.Tag))) + emptyFile := buffer.Bytes() + source := bytes.NewReader(emptyFile) + container, _ := movie.Read(source, text.DefaultCodepage()) + + assert.NotNil(t, container) +} + +func TestReadReturnsErrorOnMissingTag(t *testing.T) { + emptyFile := make([]byte, 0x100+0x300) + source := bytes.NewReader(emptyFile) + _, err := movie.Read(source, text.DefaultCodepage()) + + assert.Errorf(t, err, "Not a MOVI format") +} + +func TestReadReturnsContainerWithBasicPropertiesSet(t *testing.T) { + buffer := bytes.NewBufferString(format.Tag) + buffer.Write(make([]byte, 0x100+0x300-len(format.Tag))) + emptyFile := buffer.Bytes() + + emptyFile[0x10] = 0x80 + emptyFile[0x11] = 0x40 + emptyFile[0x12] = 0x03 + emptyFile[0x18] = 0x80 + emptyFile[0x19] = 0x02 + emptyFile[0x1A] = 0xE0 + emptyFile[0x1B] = 0x01 + emptyFile[0x26] = 0x22 + emptyFile[0x27] = 0x56 + + source := bytes.NewReader(emptyFile) + container, _ := movie.Read(source, text.DefaultCodepage()) + + assert.Equal(t, uint16(640), container.Video.Width) + assert.Equal(t, uint16(480), container.Video.Height) + assert.Equal(t, uint16(22050), uint16(container.Audio.Sound.SampleRate)) +} + +func TestReadReturnsContainerWithAudioData(t *testing.T) { + testData := []byte{0x01, 0x02, 0x03, 0x04, 0x05} + buffer := bytes.NewBufferString(format.Tag) + buffer.Write(make([]byte, 0x100+0x300-len(format.Tag))) + buffer.Write(make([]byte, 0xC00)) + buffer.Write(testData) + raw := buffer.Bytes() + + raw[0x04] = 2 + // size of index table + raw[0x08] = 0x00 + raw[0x09] = 0x0C + + // index entry 0 + raw[0x0400+3] = 0x02 + raw[0x0400+4] = 0x00 + raw[0x0400+5] = 0x10 + // index entry 1 + raw[0x0408+3] = 0x00 + raw[0x0408+4] = byte(len(testData)) + raw[0x0408+5] = 0x10 + + source := bytes.NewReader(raw) + container, _ := movie.Read(source, text.DefaultCodepage()) + + assert.Equal(t, testData, container.Audio.Sound.Samples) +} diff --git a/modding/hacked/ss1/content/movie/Scene.go b/modding/hacked/ss1/content/movie/Scene.go new file mode 100644 index 0000000..2322c08 --- /dev/null +++ b/modding/hacked/ss1/content/movie/Scene.go @@ -0,0 +1,20 @@ +package movie + +import ( + "time" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" +) + +// Scene describes a series of frames that share a common palette. +// Ideally, they also share the same framerate. +type Scene struct { + Palette bitmap.Palette + Frames []Frame +} + +// Frame describes a bitmap and how long it shall be displayed. +type Frame struct { + Pixels []byte + DisplayTime time.Duration +} diff --git a/modding/hacked/ss1/content/movie/Subtitles.go b/modding/hacked/ss1/content/movie/Subtitles.go new file mode 100644 index 0000000..873090b --- /dev/null +++ b/modding/hacked/ss1/content/movie/Subtitles.go @@ -0,0 +1,103 @@ +package movie + +import ( + "time" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// Subtitles contains all the subtitles in all languages. +type Subtitles struct { + PerLanguage [resource.LanguageCount]SubtitleList +} + +func (sub *Subtitles) add(lang resource.Language, timestamp time.Duration, text string) { + sub.PerLanguage[lang].Entries = append(sub.PerLanguage[lang].Entries, Subtitle{ + Timestamp: timestamp, + Text: text, + }) +} + +// ArePresent returns true if at least one language makes use of subtitles. +func (sub Subtitles) ArePresent() bool { + for _, lang := range sub.PerLanguage { + if len(lang.Entries) > 0 { + return true + } + } + return false +} + +func (sub Subtitles) duration() format.Timestamp { + var highest time.Duration + for _, list := range sub.PerLanguage { + for _, sub := range list.Entries { + if highest < sub.Timestamp { + highest = sub.Timestamp + } + } + } + return format.TimestampFromDuration(highest) +} + +func (sub Subtitles) encode(cp text.Codepage) [][]format.EntryBucket { + if !sub.ArePresent() { + return nil + } + bucketsPerLanguage := make([][]format.EntryBucket, len(sub.PerLanguage)+1) + + // Ensure a subtitle area is defined. + // The area is hardcoded. While the engine respects any area, placing the text in the + // frame area will have the pixels become overwritten. As such, there are many "wrong" options, + // and only a few right ones. There's no need to make them editable. + bucketsPerLanguage[0] = []format.EntryBucket{{ + Priority: format.EntryBucketPrioritySubtitleControl, + Timestamp: format.Timestamp{}, + Entries: []format.Entry{{ + Timestamp: format.Timestamp{}, + Data: format.SubtitleEntryData{ + Control: format.SubtitleArea, + Text: cp.Encode("20 365 620 395 CLR"), + }, + }}, + }} + for index, lang := range sub.PerLanguage { + bucketsPerLanguage[index+1] = lang.encode(format.SubtitleControlForLanguage(resource.Language(index)), cp) + } + return bucketsPerLanguage +} + +// SubtitleList describes the textual representation of a movie in one language. +type SubtitleList struct { + Entries []Subtitle +} + +func (sub SubtitleList) encode(control format.SubtitleControl, cp text.Codepage) []format.EntryBucket { + buckets := make([]format.EntryBucket, 0, len(sub.Entries)) + for _, entry := range sub.Entries { + buckets = append(buckets, entry.encode(control, cp)) + } + return buckets +} + +// Subtitle is a timestamped text for subtitles. +type Subtitle struct { + Timestamp time.Duration + Text string +} + +func (sub Subtitle) encode(control format.SubtitleControl, cp text.Codepage) format.EntryBucket { + return format.EntryBucket{ + Priority: format.EntryBucketPrioritySubtitle, + Timestamp: format.TimestampFromDuration(sub.Timestamp), + Entries: []format.Entry{{ + Timestamp: format.TimestampFromDuration(sub.Timestamp), + Data: format.SubtitleEntryData{ + Control: control, + Text: cp.Encode(sub.Text), + }, + }}, + } +} diff --git a/modding/hacked/ss1/content/movie/Video.go b/modding/hacked/ss1/content/movie/Video.go new file mode 100644 index 0000000..3b9e249 --- /dev/null +++ b/modding/hacked/ss1/content/movie/Video.go @@ -0,0 +1,208 @@ +package movie + +import ( + "context" + "time" + + "github.com/inkyblackness/hacked/ss1/content/bitmap" + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" +) + +// Constants for video. +const ( + HighResDefaultWidth = 600 + HighResDefaultHeight = 300 +) + +// Video describes the visual part of a movie. +type Video struct { + // Width is the width of the video in pixel. + Width uint16 + // Height is the height of the video in pixel. + Height uint16 + // Scenes contain the frames of the video. + Scenes []HighResScene +} + +// StartPalette returns the palette of the first scene. If no scene is present, a black palette is returned. +func (video Video) StartPalette() bitmap.Palette { + if len(video.Scenes) == 0 { + return bitmap.Palette{} + } + return video.Scenes[0].palette +} + +func (video Video) duration() format.Timestamp { + var sum format.Timestamp + for _, scene := range video.Scenes { + sum = sum.Plus(scene.duration()) + } + return sum +} + +func (video Video) encode() []format.EntryBucket { + var sceneTime format.Timestamp + var buckets []format.EntryBucket + for index, scene := range video.Scenes { + buckets = append(buckets, scene.encode(sceneTime, index != 0)...) + sceneTime = sceneTime.Plus(scene.duration()) + } + return buckets +} + +// Decompress unpacks all the frames of all scenes. +func (video Video) Decompress() ([]Scene, error) { + var scenes []Scene + width := int(video.Width) + height := int(video.Height) + frameBuffer := make([]byte, width*height) + decoderBuilder := compression.NewFrameDecoderBuilder(width, height) + decoderBuilder.ForStandardFrame(frameBuffer, width) + + cloneFramebuffer := func() []byte { + bufferCopy := make([]byte, len(frameBuffer)) + copy(bufferCopy, frameBuffer) + return bufferCopy + } + + for _, compressedScene := range video.Scenes { + decoderBuilder.WithControlWords(compressedScene.controlWords) + decoderBuilder.WithPaletteLookupList(compressedScene.paletteLookup) + decoder := decoderBuilder.Build() + var scene Scene + scene.Palette = compressedScene.palette + for _, compressedFrame := range compressedScene.frames { + err := decoder.Decode(compressedFrame.bitstream, compressedFrame.maskstream) + if err != nil { + return nil, err + } + scene.Frames = append(scene.Frames, Frame{ + Pixels: cloneFramebuffer(), + DisplayTime: compressedFrame.displayTime.ToDuration(), + }) + } + scenes = append(scenes, scene) + } + return scenes, nil +} + +// HighResScene is a set of frames with high-resolution compression. +type HighResScene struct { + palette bitmap.Palette + paletteLookup []byte + controlWords []compression.ControlWord + frames []HighResFrame +} + +// HighResSceneFrom compresses given scene and returns the compression result. +func HighResSceneFrom(ctx context.Context, scene Scene, width, height int) (HighResScene, error) { + encoder := compression.NewSceneEncoder(width, height) + for _, frame := range scene.Frames { + err := encoder.AddFrame(frame.Pixels) + if err != nil { + return HighResScene{}, err + } + if ctx.Err() != nil { + return HighResScene{}, ctx.Err() + } + } + words, paletteLookup, frames, err := encoder.Encode(ctx) + if err != nil { + return HighResScene{}, err + } + compressedScene := HighResScene{ + palette: scene.Palette, + paletteLookup: paletteLookup, + controlWords: words, + frames: make([]HighResFrame, len(frames)), + } + for index, frame := range frames { + compressedScene.frames[index] = HighResFrame{ + bitstream: frame.Bitstream, + maskstream: frame.Maskstream, + displayTime: format.TimestampFromDuration(scene.Frames[index].DisplayTime), + } + } + + return compressedScene, nil +} + +// WithFrameDisplayTime returns a new scene instance with the given display time set for all frames. +func (scene HighResScene) WithFrameDisplayTime(displayTime time.Duration) HighResScene { + newScene := scene + newFrames := make([]HighResFrame, len(scene.frames)) + for index, frame := range scene.frames { + newFrames[index] = frame.WithDisplayTime(displayTime) + } + newScene.frames = newFrames + return newScene +} + +func (scene HighResScene) duration() format.Timestamp { + var sum format.Timestamp + for _, frame := range scene.frames { + sum = sum.Plus(frame.duration()) + } + return sum +} + +func (scene HighResScene) encode(start format.Timestamp, withPalette bool) []format.EntryBucket { + buckets := make([]format.EntryBucket, 0, len(scene.frames)+1) + controlEntries := []format.Entry{ + {Timestamp: format.Timestamp{}, Data: format.PaletteLookupEntryData{List: scene.paletteLookup}}, + {Timestamp: format.Timestamp{}, Data: format.ControlDictionaryEntryData{Words: scene.controlWords}}, + } + if withPalette { + controlEntries = append(controlEntries, + format.Entry{Timestamp: start, Data: format.PaletteResetEntryData{}}, + format.Entry{Timestamp: start, Data: format.PaletteEntryData{Colors: scene.palette}}) + } + buckets = append(buckets, + format.EntryBucket{ + Priority: format.EntryBucketPriorityVideoControl, + Timestamp: start, + Entries: controlEntries, + }) + frameTime := start + for _, frame := range scene.frames { + buckets = append(buckets, frame.encode(frameTime)) + frameTime = frameTime.Plus(frame.displayTime) + } + + return buckets +} + +// HighResFrame contains the compressed information of a high-resolution picture in a scene. +type HighResFrame struct { + bitstream []byte + maskstream []byte + displayTime format.Timestamp +} + +// WithDisplayTime returns a new instance with the given display time set. +func (frame HighResFrame) WithDisplayTime(displayTime time.Duration) HighResFrame { + newFrame := frame + newFrame.displayTime = format.TimestampFromDuration(displayTime) + return newFrame +} + +func (frame HighResFrame) duration() format.Timestamp { + return frame.displayTime +} + +func (frame HighResFrame) encode(start format.Timestamp) format.EntryBucket { + return format.EntryBucket{ + Priority: format.EntryBucketPriorityFrame, + Timestamp: start, + Entries: []format.Entry{ + { + Timestamp: start, + Data: format.HighResVideoEntryData{ + Bitstream: frame.bitstream, + Maskstream: frame.maskstream, + }, + }, + }, + } +} diff --git a/modding/hacked/ss1/content/movie/Write.go b/modding/hacked/ss1/content/movie/Write.go new file mode 100644 index 0000000..1f2f355 --- /dev/null +++ b/modding/hacked/ss1/content/movie/Write.go @@ -0,0 +1,143 @@ +package movie + +import ( + "bytes" + "encoding/binary" + "io" + "sort" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" + "github.com/inkyblackness/hacked/ss1/content/text" +) + +// Write encodes the provided container into the given writer. +func Write(dest io.Writer, container Container, cp text.Codepage) error { + var indexEntries []format.IndexTableEntry + var header format.Header + palette := paletteDataFromContainer(container) + + // setup header + copy(header.Tag[:], bytes.NewBufferString(format.Tag).Bytes()) + endTimestamp := container.duration() + header.Duration.Number = int16(endTimestamp.Second) + header.Duration.Fraction = endTimestamp.Fraction + header.VideoWidth = container.Video.Width + header.VideoHeight = container.Video.Height + + if header.VideoWidth != 0 { + header.VideoBitsPerPixel = 8 + header.VideoPalettePresent = 1 + header.VideoFrameRate = format.FixFromFloat(12.5) // take a good guess on what a typical framerate would be. + } + if !container.Audio.Sound.Empty() { + header.AudioChannelCount = 1 + header.AudioBytesPerSample = 1 + header.AudioSampleRate = format.FixFromFloat(container.Audio.Sound.SampleRate) + } + + var buckets []format.EntryBucket + buckets = append(buckets, container.Audio.encode()...) + buckets = append(buckets, container.Video.encode()...) + subtitleBucketsList := container.Subtitles.encode(cp) + for _, subtitleBuckets := range subtitleBucketsList { + buckets = append(buckets, subtitleBuckets...) + } + sort.Slice(buckets, func(a, b int) bool { + bucketA := buckets[a] + bucketB := buckets[b] + if bucketA.Timestamp.IsBefore(bucketB.Timestamp) { + return true + } + if bucketB.Timestamp.IsBefore(bucketA.Timestamp) { + return false + } + return bucketA.Priority < bucketB.Priority + }) + + // create index + var entries []format.Entry + for _, bucket := range buckets { + entries = append(entries, bucket.Entries...) + } + moveSubtitlesAfterSceneChange(entries) + + for _, dataEntry := range entries { + indexEntry := format.IndexTableEntry{ + Type: byte(dataEntry.Data.Type()), + DataOffset: header.ContentSize, + TimestampSecond: dataEntry.Timestamp.Second, + TimestampFraction: dataEntry.Timestamp.Fraction, + } + + header.ContentSize += int32(len(dataEntry.Data.Bytes())) + indexEntries = append(indexEntries, indexEntry) + } + // calculate size fields + header.IndexSize = int32(format.IndexTableSizeFor(len(indexEntries) + 1)) + dataStartOffset := format.HeaderSize + int32(len(palette)) + header.IndexSize + for i := range indexEntries { + indexEntries[i].DataOffset += dataStartOffset + } + + // determine end + lastEntry := format.IndexTableEntry{ + TimestampSecond: byte(header.Duration.Number), + TimestampFraction: header.Duration.Fraction, + Type: byte(format.DataTypeEndOfMedia), + DataOffset: dataStartOffset + header.ContentSize} + indexEntries = append(indexEntries, lastEntry) + header.IndexEntryCount = int32(len(indexEntries)) + + // write data + err := binary.Write(dest, binary.LittleEndian, &header) + if err != nil { + return err + } + _, err = dest.Write(palette) + if err != nil { + return err + } + err = binary.Write(dest, binary.LittleEndian, indexEntries) + if err != nil { + return err + } + _, err = dest.Write(make([]byte, int(header.IndexSize)-int(header.IndexEntryCount)*format.IndexTableEntrySize)) + if err != nil { + return err + } + for _, dataEntry := range entries { + _, err = dest.Write(dataEntry.Data.Bytes()) + if err != nil { + return err + } + } + return nil +} + +func moveSubtitlesAfterSceneChange(entries []format.Entry) { + nextSceneChangeIndex := -1 + var nextSceneChangeTimestamp format.Timestamp + delta := format.TimestampFromSeconds(0.5) + for i := len(entries) - 1; i >= 0; i-- { + entry := entries[i] + + switch entry.Data.Type() { + case format.DataTypePalette: + nextSceneChangeIndex = i + nextSceneChangeTimestamp = entry.Timestamp + case format.DataTypeSubtitle: + if (nextSceneChangeIndex > 0) && nextSceneChangeTimestamp.IsBefore(entry.Timestamp.Plus(delta)) { + copy(entries[i:nextSceneChangeIndex], entries[i+1:nextSceneChangeIndex+1]) + entries[nextSceneChangeIndex] = entry + nextSceneChangeIndex-- + } + } + } +} + +func paletteDataFromContainer(container Container) []byte { + palette := container.Video.StartPalette() + buf := bytes.NewBuffer(nil) + _ = binary.Write(buf, binary.LittleEndian, &palette) + return buf.Bytes() +} diff --git a/modding/hacked/ss1/content/movie/Write_test.go b/modding/hacked/ss1/content/movie/Write_test.go new file mode 100644 index 0000000..b0ecd2d --- /dev/null +++ b/modding/hacked/ss1/content/movie/Write_test.go @@ -0,0 +1,50 @@ +package movie_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/content/text" +) + +func TestWriteOfEmptyContainerCreatesMinimumSizeData(t *testing.T) { + var container movie.Container + buffer := bytes.NewBuffer(nil) + + err := movie.Write(buffer, container, text.DefaultCodepage()) + require.Nil(t, err) + assert.Equal(t, 0x0800, len(buffer.Bytes())) +} + +func TestWriteCanSaveEmptyContainer(t *testing.T) { + var container movie.Container + buffer := bytes.NewBuffer(nil) + + err := movie.Write(buffer, container, text.DefaultCodepage()) + require.Nil(t, err) + + _, err = movie.Read(bytes.NewReader(buffer.Bytes()), text.DefaultCodepage()) + + require.Nil(t, err) +} + +func TestWriteSavesAudio(t *testing.T) { + dataBytes := []byte{0x01, 0x02, 0x03} + var container movie.Container + container.Audio.Sound.SampleRate = 22050.0 + container.Audio.Sound.Samples = dataBytes + buffer := bytes.NewBuffer(nil) + + err := movie.Write(buffer, container, text.DefaultCodepage()) + require.Nil(t, err) + + result, err := movie.Read(bytes.NewReader(buffer.Bytes()), text.DefaultCodepage()) + + require.Nil(t, err) + require.NotNil(t, result) + assert.Equal(t, dataBytes, result.Audio.Sound.Samples) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/BitstreamReader.go b/modding/hacked/ss1/content/movie/internal/compression/BitstreamReader.go new file mode 100644 index 0000000..ac287be --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/BitstreamReader.go @@ -0,0 +1,87 @@ +package compression + +// BitstreamReader is a utility to read big-endian integer values of arbitrary bit size from a bitstream. +type BitstreamReader struct { + source []byte + nextSourceIndex int + sourceBitLen uint64 + + currentBitPos uint64 + + buffer uint64 + bitsBuffered uint64 +} + +// NewBitstreamReader returns a new instance of a bistream reader using the provided byte slice as source. +func NewBitstreamReader(data []byte) *BitstreamReader { + return &BitstreamReader{ + source: data, + sourceBitLen: uint64(len(data)) * 8, + } +} + +func (reader *BitstreamReader) bufferNextByte() { + reader.buffer = (reader.buffer << 8) | uint64(reader.source[reader.nextSourceIndex]) + reader.bitsBuffered += 8 + reader.nextSourceIndex++ +} + +// Exhausted returns true if the current position is at or beyond the possible length. +// Reading an exhausted stream yields only zeroes. +func (reader *BitstreamReader) Exhausted() bool { + return reader.currentBitPos >= reader.sourceBitLen +} + +// Read returns a value with the requested bit size, right aligned, as a uint32. +// Reading does not advance the current position. A successful read of a certain size will return the same +// value when called repeatedly with the same parameter. +// Reading is possible beyond the available size. For the missing bits, a value of 0 is provided. +// +// The function panics when reading more than 32 bits. +func (reader *BitstreamReader) Read(bits int) (result uint32) { + if bits > 32 { + panic("Limit of bit count: 32") + } + + available := reader.sourceBitLen - reader.currentBitPos + toRead := uint64(bits) + if toRead > available { + toRead = available + } + + for reader.bitsBuffered < toRead { + reader.bufferNextByte() + } + result = uint32(reader.buffer >> (reader.bitsBuffered - toRead) & ^(uint64(0xFFFFFFFFFFFFFFFF) << toRead)) + result <<= uint64(bits) - toRead + + return +} + +// Advance skips the provided amount of bits and puts the current position there. +// Successive read operations will return values from the new position. +// It is possible to advance beyond the available length. +// +// This function panics when advancing with negative values. +func (reader *BitstreamReader) Advance(bits int) { + if bits < 0 { + panic("Can only advance forward") + } + + newIndex := reader.currentBitPos + uint64(bits) + if newIndex > reader.sourceBitLen { + newIndex = reader.sourceBitLen + } + reader.currentBitPos = newIndex + if uint64(bits) > reader.bitsBuffered { + reader.bitsBuffered = 0 + reader.nextSourceIndex = int(reader.currentBitPos / 8) + remainder := reader.currentBitPos % 8 + if remainder > 0 { + reader.bufferNextByte() + reader.bitsBuffered -= remainder + } + } else { + reader.bitsBuffered -= uint64(bits) + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/BitstreamReader_test.go b/modding/hacked/ss1/content/movie/internal/compression/BitstreamReader_test.go new file mode 100644 index 0000000..a8f3297 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/BitstreamReader_test.go @@ -0,0 +1,181 @@ +package compression_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" + + "github.com/stretchr/testify/assert" +) + +func TestBitstreamReadPanicsForMoreThan32Bits(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0x11, 0x22, 0x33, 0x44}) + + assert.Panicsf(t, func() { reader.Read(33) }, "Limit of bit count: 32") +} + +func TestBitstreamReadReturnsValueOfRequestedBitSize(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xAF}) + + result := reader.Read(3) + + assert.Equal(t, uint32(5), result) +} + +func TestBitstreamRepeatedReadReturnsSameValue(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xAF}) + + result1 := reader.Read(3) + result2 := reader.Read(3) + + assert.Equal(t, uint32(5), result1) + assert.Equal(t, result1, result2) +} + +func TestBitstreamReadReturnsZeroesForBitsBeyondEndA(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xAF}) + + result := reader.Read(9) + + assert.Equal(t, uint32(0x15E), result) +} + +func TestBitstreamReadReturnsZeroesForBitsBeyondEndB(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xBF}) + + result := reader.Read(32) + + assert.Equal(t, uint32(0xBF000000), result) +} + +func TestBitstreamAdvancePanicsForNegativeValues(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0x11, 0x22, 0x33, 0x44}) + + assert.Panicsf(t, func() { reader.Advance(-10) }, "Can only advance forward") +} + +func TestBitstreamAdvanceLetsReadFurtherBits(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xAF}) + + reader.Advance(2) + result := reader.Read(4) + + assert.Equal(t, uint32(0x0B), result) +} + +func TestBitstreamAdvanceToEndIsPossible(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xAF}) + + reader.Advance(8) + result := reader.Read(8) + + assert.Equal(t, uint32(0), result) +} + +func TestBitstreamInternalBufferDoesNotLoseData(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0x7F, 0xFF, 0xFF, 0xFF, 0x80}) + + reader.Advance(1) + result := reader.Read(32) + + assert.Equal(t, uint32(0xFFFFFFFF), result) +} + +func TestBitstreamAdvanceCanJumpToLastBit(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0x00, 0x00, 0x01}) + + reader.Advance(23) + result := reader.Read(1) + + assert.Equal(t, uint32(1), result) +} + +func TestBitstreamReadAdvanceBeyondFirstRead(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF, 0x00, 0xFA}) + + reader.Read(10) + reader.Advance(20) + result := reader.Read(4) + + assert.Equal(t, uint32(0x0A), result) +} + +func TestBitstreamReadAdvanceWithinFirstRead(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF, 0x00, 0xFA}) + + reader.Read(10) + reader.Advance(4) + result := reader.Read(20) + + assert.Equal(t, uint32(0xF00FA), result) +} + +func TestBitstreamReadOfZeroBitsIsPossibleMidStream(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF}) + + reader.Read(4) + result := reader.Read(0) + + assert.Equal(t, uint32(0), result) +} + +func TestBitstreamReadOfZeroBitsIsPossibleAtEnd(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF}) + + reader.Advance(8) + result := reader.Read(0) + + assert.Equal(t, uint32(0), result) +} + +func TestBitstreamReadOfZeroBitsIsPossibleWithEmptySource(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{}) + + result := reader.Read(0) + + assert.Equal(t, uint32(0), result) +} + +func TestBitstreamAdvanceBeyondEndIsPossible(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF}) + + reader.Advance(9) + result := reader.Read(32) + + assert.Equal(t, uint32(0), result) +} + +func TestBitstreamExhaustedReturnsFalseForAvailableBits(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF}) + + result := reader.Exhausted() + + assert.False(t, result) +} + +func TestBitstreamExhaustedReturnsFalseForStillOneAvailableBit(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF}) + + reader.Advance(7) + result := reader.Exhausted() + + assert.False(t, result) +} + +func TestBitstreamExhaustedReturnsTrueAfterAdvancingToEnd(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF, 0xFF}) + + reader.Advance(16) + result := reader.Exhausted() + + assert.True(t, result) +} + +func TestBitstreamExhaustedReturnsTrueAfterAdvancingBeyondEnd(t *testing.T) { + reader := compression.NewBitstreamReader([]byte{0xFF, 0xFF}) + + reader.Advance(30) + result := reader.Exhausted() + + assert.True(t, result) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/BitstreamWriter.go b/modding/hacked/ss1/content/movie/internal/compression/BitstreamWriter.go new file mode 100644 index 0000000..761972e --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/BitstreamWriter.go @@ -0,0 +1,34 @@ +package compression + +// BitstreamWriter is a utility to write big-endian integer values of arbitrary bit size to a bitstream. +type BitstreamWriter struct { + buf []byte + + offset uint +} + +// Buffer returns the currently stored buffer. +func (w BitstreamWriter) Buffer() []byte { + return w.buf +} + +// Write stores the given amount of bits from the provided value. +// Trying to write more than 32 bits will cause panic. +func (w *BitstreamWriter) Write(bits uint, value uint32) { + if bits > 32 { + panic("maximum of 32 bits are possible to be written") + } + for bits > 0 { + temp := (uint64(value) & ^(^uint64(0) << bits)) << (40 - bits - w.offset) + if w.offset == 0 { + w.buf = append(w.buf, 0x00) + } + w.buf[len(w.buf)-1] |= byte(temp >> 32) + written := 8 - w.offset + if written > bits { + written = bits + } + bits -= written + w.offset = (w.offset + written) % 8 + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/BitstreamWriter_test.go b/modding/hacked/ss1/content/movie/internal/compression/BitstreamWriter_test.go new file mode 100644 index 0000000..08be2b3 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/BitstreamWriter_test.go @@ -0,0 +1,99 @@ +package compression_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type BitstreamWriterSuite struct { + suite.Suite + + writer compression.BitstreamWriter +} + +func TestBitstreamWriter(t *testing.T) { + suite.Run(t, new(BitstreamWriterSuite)) +} + +func (suite *BitstreamWriterSuite) SetupTest() { + suite.writer = compression.BitstreamWriter{} +} + +func (suite *BitstreamWriterSuite) TestWriteMoreThan32BitsPanics() { + assert.Panics(suite.T(), func() { suite.whenWriting(33, 0) }) +} + +func (suite *BitstreamWriterSuite) TestWriteZeroBitsIsIgnored() { + suite.whenWriting(0, 1) + suite.thenBufferShouldBe([]byte{}) +} + +func (suite *BitstreamWriterSuite) TestWriteOneBit() { + suite.whenWriting(1, 1) + suite.thenBufferShouldBe([]byte{0x80}) +} + +func (suite *BitstreamWriterSuite) TestWriteEightBits() { + suite.whenWriting(8, 0xEF) + suite.thenBufferShouldBe([]byte{0xEF}) +} + +func (suite *BitstreamWriterSuite) TestWriteTwelveBits() { + suite.whenWriting(12, 0xFEF) + suite.thenBufferShouldBe([]byte{0xFE, 0xF0}) +} + +func (suite *BitstreamWriterSuite) TestWriteOneBitTwice() { + suite.givenWritten(1, 1) + suite.whenWriting(1, 1) + suite.thenBufferShouldBe([]byte{0xC0}) +} + +func (suite *BitstreamWriterSuite) TestWriteArbitrary1() { + suite.givenWritten(1, 1) + suite.givenWritten(10, 0x2AA) + suite.whenWriting(16, 0xFFFF) + suite.thenBufferShouldBe([]byte{0xD5, 0x5F, 0xFF, 0xE0}) // [1 10 10101] [010 11111] [11111111] [111 00000] +} + +func (suite *BitstreamWriterSuite) TestWriteArbitrary2() { + suite.givenWritten(1, 1) + suite.givenWritten(10, 0x2AA) + suite.whenWriting(24, 0xFFFFFF) + suite.thenBufferShouldBe([]byte{0xD5, 0x5F, 0xFF, 0xFF, 0xE0}) // extension of Arbitrary1 +} + +func (suite *BitstreamWriterSuite) TestWriteLessBitsThanGiven() { + suite.givenWritten(4, 0x0) + suite.whenWriting(8, 0xFFF) + suite.thenBufferShouldBe([]byte{0x0F, 0xF0}) +} + +func (suite *BitstreamWriterSuite) TestWriteArbitrary3() { + suite.givenWritten(1, 1) + suite.givenWritten(10, 0x2AA) + suite.whenWriting(32, 0xFFFFFFFF) + suite.thenBufferShouldBe([]byte{0xD5, 0x5F, 0xFF, 0xFF, 0xFF, 0xE0}) // extension of Arbitrary1+2 +} + +func (suite *BitstreamWriterSuite) givenWritten(bits uint, value uint32) { + suite.writer.Write(bits, value) +} + +func (suite *BitstreamWriterSuite) whenWriting(bits uint, value uint32) { + suite.writer.Write(bits, value) +} + +func (suite *BitstreamWriterSuite) thenBufferShouldBe(expected []byte) { + suite.T().Helper() + buf := suite.writer.Buffer() + if len(expected) == 0 { + assert.Equal(suite.T(), 0, len(buf)) + } else { + assert.Equal(suite.T(), expected, buf) + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/Compression_test.go b/modding/hacked/ss1/content/movie/internal/compression/Compression_test.go new file mode 100644 index 0000000..a229cdd --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/Compression_test.go @@ -0,0 +1,241 @@ +package compression_test + +import ( + "context" + "fmt" + "io/ioutil" + "math/rand" + "path/filepath" + "testing" + "time" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type CompressionSuite struct { + suite.Suite +} + +func TestTileSideLength(t *testing.T) { + assert.Equal(t, 4, compression.TileSideLength, "The following tests assume a tile side length of 4") +} + +func TestCompressionTest(t *testing.T) { + suite.Run(t, new(CompressionSuite)) +} + +func (suite *CompressionSuite) SetupTest() { + +} + +func (suite *CompressionSuite) TestOneFrameOneTile() { + frame0 := []byte{ + 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, + 0x0D, 0x0E, 0x0F, 0x10, + } + suite.verifyCompression(4, 4, frame0) +} + +func (suite *CompressionSuite) TestTwoFramesOneTile() { + frame0 := []byte{ + 0x01, 0x02, 0x03, 0x04, + 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, + 0x0D, 0x0E, 0x0F, 0x10, + } + frame1 := []byte{ + 0x20, 0x21, 0x22, 0x23, + 0x24, 0x25, 0x26, 0x27, + 0x28, 0x29, 0x2A, 0x2B, + 0x2C, 0x2D, 0x2E, 0x2F, + } + suite.verifyCompression(4, 4, frame0, frame1) +} + +func (suite *CompressionSuite) TestTwoTiles() { + frame0 := []byte{ + 0x01, 0x02, 0x03, 0x04, 0x20, 0x21, 0x22, 0x23, + 0x05, 0x06, 0x07, 0x08, 0x24, 0x25, 0x26, 0x27, + 0x09, 0x0A, 0x0B, 0x0C, 0x28, 0x29, 0x2A, 0x2B, + 0x0D, 0x0E, 0x0F, 0x10, 0x2C, 0x2D, 0x2E, 0x2F, + } + suite.verifyCompression(8, 4, frame0) +} + +func (suite *CompressionSuite) verifyCompression(width, height int, inFrames ...[]byte) { + suite.T().Helper() + verifyCompression(suite.T(), width, height, inFrames...) +} + +func BenchmarkRecompressionDeth00(b *testing.B) { + recompress(b, "deth", "scene00") +} + +func BenchmarkRecompressionDeth01(b *testing.B) { + recompress(b, "deth", "scene01") +} + +func BenchmarkRecompressionDeth02(b *testing.B) { + recompress(b, "deth", "scene02") +} + +func recompress(tb testing.TB, dir1, dir2 string) { + tb.Helper() + scenepath := filepath.Join(".", "_testdata", dir1, dir2) + read := func(name string) []byte { + data, err := ioutil.ReadFile(filepath.Join(scenepath, name)) + require.Nil(tb, err, "no error expected reading %v", name) + return data + } + controlWordsData := read("controlDict.bin") + controlWords, err := compression.UnpackControlWords(controlWordsData) + require.Nil(tb, err, "no error expected unpacking control words") + paletteLookup := read("paletteLookup.bin") + + width := 600 + height := 300 + frame := make([]byte, width*height) + decoderBuilder := compression.NewFrameDecoderBuilder(600, 300) + + logStatistics(tb, "In", controlWords, paletteLookup) + + decoderBuilder.WithControlWords(controlWords) + decoderBuilder.WithPaletteLookupList(paletteLookup) + decoderBuilder.ForStandardFrame(frame, width) + decoder := decoderBuilder.Build() + canDecodeFrame := func(index int) bool { + bitstream, err := ioutil.ReadFile(filepath.Join(scenepath, fmt.Sprintf("frame%02d_bitstream.bin", index))) + if err != nil { + return false + } + maskstream, err := ioutil.ReadFile(filepath.Join(scenepath, fmt.Sprintf("frame%02d_maskstream.bin", index))) + if err != nil { + return false + } + + tb.Logf("InStatistics F%2d: Bitstream: %v, Maskstream: %v", index, len(bitstream), len(maskstream)) + err = decoder.Decode(bitstream, maskstream) + if err != nil { + tb.Logf("Failed to decode frame F%2d: %v", index, err) + return false + } + return true + } + frameIndex := 0 + var frames [][]byte + for canDecodeFrame(frameIndex) { + frameCopy := make([]byte, len(frame)) + copy(frameCopy, frame) + frames = append(frames, frameCopy) + frameIndex++ + } + + tb.Logf("verifying compression of %v frames\n", len(frames)) + verifyCompression(tb, width, height, frames...) +} + +func BenchmarkRandomFrames(b *testing.B) { + // With a frame size of 600x300 we have 150*75=11250 tiles per frame. + // The maximum palette offset is 0x1FFFF, allowing for 8191 entries of full 16 byte (fully random tile) palettes. + // This means not even one frame of completely randomized tiles can be encoded. + // As a result, this test will first create a set of 4000 random tiles, and then create a sequence of frames + // using this set of tiles, instead of creating complete random frames. + // Still, this test is rather slow, so it is moved to be a benchmark for now. + + seed := time.Now().UnixNano() + b.Logf("Running with seed %v", seed) + hTiles := 150 + vTiles := 75 + width := 4 * hTiles + height := 4 * vTiles + r := rand.New(rand.NewSource(seed)) + tiles := make([][4][4]byte, 4000) + for tileIndex := 0; tileIndex < len(tiles); tileIndex++ { + tile := &tiles[tileIndex] + for y := 0; y < 4; y++ { + for x := 0; x < 4; x++ { + tile[y][x] = byte(1 + r.Intn(255)) // value of 0x00 may not be encoded. + } + } + } + + frames := make([][]byte, 5) + for frameIndex := 0; frameIndex < len(frames); frameIndex++ { + frame := make([]byte, width*height) + for y := 0; y < height; y += 4 { + for x := 0; x < width; x += 4 { + tile := &tiles[r.Intn(len(tiles))] + for line := 0; line < 4; line++ { + copy(frame[(y+line)*width+x:], tile[line][:]) + } + } + } + frames[frameIndex] = frame + } + + b.ResetTimer() + verifyCompression(b, width, height, frames...) +} + +func verifyCompression(tb testing.TB, width, height int, inFrames ...[]byte) { + tb.Helper() + encoder := compression.NewSceneEncoder(width, height) + for frameIndex, frame := range inFrames { + require.Equal(tb, width*height, len(frame), fmt.Sprintf("Length of frame %d is wrong for dimension", frameIndex)) + err := encoder.AddFrame(frame) + require.Nil(tb, err, fmt.Sprintf("no error expected adding frame %d: %v", frameIndex, err)) + } + controlWords, paletteLookup, encodedFrames, err := encoder.Encode(context.Background()) + require.Equal(tb, len(inFrames), len(encodedFrames), "expected equal amount of encoded frames for input frames") + require.Nil(tb, err, fmt.Sprintf("no error expected encoding: %v", err)) + + logStatistics(tb, "Out", controlWords, paletteLookup) + + decoderBuilder := compression.NewFrameDecoderBuilder(width, height) + decoderBuilder.WithControlWords(controlWords) + decoderBuilder.WithPaletteLookupList(paletteLookup) + frameBuffer := make([]byte, width*height) + decoderBuilder.ForStandardFrame(frameBuffer, width) + for frameIndex, encodedFrame := range encodedFrames { + decoder := decoderBuilder.Build() + tb.Logf("Statistics F%2d: Bitstream: %v, Maskstream: %v", + frameIndex, len(encodedFrame.Bitstream), len(encodedFrame.Maskstream)) + err = decoder.Decode(encodedFrame.Bitstream, encodedFrame.Maskstream) + assert.Nil(tb, err, fmt.Sprintf("no error expected re-decoding: %v", err)) + assert.Equal(tb, inFrames[frameIndex], frameBuffer, fmt.Sprintf("Frame content mismatch for frame %d", frameIndex)) + } +} + +func logStatistics(tb testing.TB, prefix string, controlWords []compression.ControlWord, paletteLookup []byte) { + tb.Helper() + controls := make(map[compression.ControlType]int) + skips := make(map[int]int) + longOffsets := 0 + for _, word := range controlWords { + if word.IsLongOffset() { + longOffsets++ + } else { + skips[word.Count()]++ + controls[word.Type()]++ + } + } + firstWords := "" + for i := 0; i < 16; i++ { + if i < len(controlWords) { + word := controlWords[i] + if word.IsLongOffset() { + firstWords += fmt.Sprintf(" LO: %v", word.LongOffset()) + } else { + firstWords += fmt.Sprintf(" [%v %v %v]", word.Count(), word.Type(), word.Parameter()) + } + } + } + tb.Logf("%vStatistics: PaletteLookup: %vB, ControlWords: %v, LO: %v, CTRL: %v, SKIP: %v -- firstWords: %v", + prefix, len(paletteLookup), len(controlWords), longOffsets, controls, skips, firstWords) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/Constants.go b/modding/hacked/ss1/content/movie/internal/compression/Constants.go new file mode 100644 index 0000000..6915251 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/Constants.go @@ -0,0 +1,10 @@ +package compression + +const ( + bytesPerControlWord = 3 + + // TileSideLength is the number of pixel per side of a square frame tile. + TileSideLength = 4 + // PixelPerTile is the number of pixel within one frame tile. + PixelPerTile = TileSideLength * TileSideLength +) diff --git a/modding/hacked/ss1/content/movie/internal/compression/ControlType.go b/modding/hacked/ss1/content/movie/internal/compression/ControlType.go new file mode 100644 index 0000000..692348e --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/ControlType.go @@ -0,0 +1,18 @@ +package compression + +// ControlType defines how to interpret a ControlWord. +type ControlType byte + +// Control type constants are listed below. +const ( + CtrlColorTile2ColorsStatic = ControlType(0) + CtrlColorTile2ColorsMasked = ControlType(1) + CtrlColorTile4ColorsMasked = ControlType(2) + CtrlColorTile8ColorsMasked = ControlType(3) + CtrlColorTile16ColorsMasked = ControlType(4) + + CtrlSkip = ControlType(5) + + CtrlRepeatPrevious = ControlType(6) + CtrlUnknown = ControlType(7) +) diff --git a/modding/hacked/ss1/content/movie/internal/compression/ControlWord.go b/modding/hacked/ss1/content/movie/internal/compression/ControlWord.go new file mode 100644 index 0000000..254d22d --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/ControlWord.go @@ -0,0 +1,47 @@ +package compression + +// ControlWordParamLimit is the highest number the param property of a control word can take. +const ControlWordParamLimit = 0x1FFFF + +// ControlWord describes the current compression action. +type ControlWord uint32 + +// ControlWordOf returns a word with the given parameters. +func ControlWordOf(count int, controlType ControlType, param uint32) ControlWord { + return (ControlWord(count) << 20) | (ControlWord(controlType&0x7) << 17) | ControlWord(param&ControlWordParamLimit) +} + +// LongOffsetOf returns a word that indicates a long offset with given value. +func LongOffsetOf(offset uint32) ControlWord { + return ControlWord(offset & 0xFFFFF) +} + +// Packed returns the control word packed in a PackedControlWord with the given number of times. +func (word ControlWord) Packed(times int) PackedControlWord { + return PackedControlWord((uint32(word) & 0x00FFFFFF) | (uint32(times) << 24)) +} + +// Count returns the count value of the control. +func (word ControlWord) Count() int { + return int((uint32(word) >> 20) & 0xF) +} + +// IsLongOffset returns true if Count() returns 0. +func (word ControlWord) IsLongOffset() bool { + return word.Count() == 0 +} + +// LongOffset returns the long offset value. Only relevant if IsLongOffset() returns true. +func (word ControlWord) LongOffset() uint32 { + return (uint32(word) >> 0) & 0xFFFFF +} + +// Type returns the type of the control. Only relevant if IsLongOffset() returns false. +func (word ControlWord) Type() ControlType { + return ControlType((uint32(word) >> 17) & 0x7) +} + +// Parameter returns the type specific parameter value. +func (word ControlWord) Parameter() uint32 { + return (uint32(word) >> 0) & ControlWordParamLimit +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/ControlWordSequencer.go b/modding/hacked/ss1/content/movie/internal/compression/ControlWordSequencer.go new file mode 100644 index 0000000..345dacd --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/ControlWordSequencer.go @@ -0,0 +1,198 @@ +package compression + +import ( + "sort" + + "github.com/inkyblackness/hacked/ss1" +) + +const ( + errTooHighOperationOffset ss1.StringError = "too high operation offset" +) + +// TileColorOp describes one operation how a tile should be colored. +type TileColorOp struct { + Type ControlType + Offset uint32 +} + +// ControlWordSequencer receives a list of requested tile coloring operations +// and produces a sequence of low-level control words to reproduce the requested list. +type ControlWordSequencer struct { + // BitstreamIndexLimit specifies the highest value the sequencer may use to store offset values in the bitstream. + // If this value is 0, then the default of 0xFFF (12 bits) is used. + BitstreamIndexLimit uint32 + + // DirectIndexLimit specifies the amount of direct pointers into the bitstream. + // If this value is 0, then the default of BitstreamIndexLimit / 4 is used. + DirectIndexLimit uint32 + + ops map[TileColorOp]uint32 +} + +// Add extends the list of requested coloring operations with the given entry. +// The offset of the entry must be a value less than or equal ControlWordParamLimit, otherwise the function returns an error. +func (seq *ControlWordSequencer) Add(op TileColorOp) error { + if op.Offset > ControlWordParamLimit { + return errTooHighOperationOffset + } + if seq.ops == nil { + seq.ops = make(map[TileColorOp]uint32) + } + seq.ops[op]++ + return nil +} + +// Sequence packs the requested list of entries into a sequence of control words. +// An error is returned if the operations would exceed the possible storage space. +func (seq ControlWordSequencer) Sequence() (ControlWordSequence, error) { + var result ControlWordSequence + var sortedOps []TileColorOp + for op := range seq.ops { + sortedOps = append(sortedOps, op) + } + sort.Slice(sortedOps, func(a, b int) bool { + opA := sortedOps[a] + opB := sortedOps[b] + countA := seq.ops[opA] + countB := seq.ops[opB] + if countA != countB { + return countA > countB + } + if opA.Offset != opB.Offset { + return opA.Offset < opB.Offset + } + if opA.Type != opB.Type { + return opA.Type < opB.Type + } + return false + }) + + bitstreamIndexLimit := seq.BitstreamIndexLimit + if bitstreamIndexLimit == 0 { + bitstreamIndexLimit = 0xFFF + } + maximumDirect := seq.DirectIndexLimit + if maximumDirect > bitstreamIndexLimit { + maximumDirect = bitstreamIndexLimit + } else if maximumDirect == 0 { + maximumDirect = bitstreamIndexLimit / 4 + } + requiredExtensions := uint32(0) + + totalOps := uint32(len(sortedOps)) + if totalOps > maximumDirect { + requiredExtensions = (totalOps - maximumDirect + 15) / 16 + if requiredExtensions > (bitstreamIndexLimit - maximumDirect) { + return result, tooManyWordsError{ + Total: totalOps, + MaximumDirect: maximumDirect, + BitstreamIndexLimit: bitstreamIndexLimit, + } + } + } + extensionStart := maximumDirect + requiredExtensions + + result.opPaths = make(map[TileColorOp]nestedTileColorOp) + for opIndex, op := range sortedOps { + if uint32(opIndex) == maximumDirect { + // write required amount of long offset entries + for i := uint32(0); i < requiredExtensions; i++ { + result.words = append(result.words, LongOffsetOf(extensionStart+i*16)) + } + } + if uint32(opIndex) >= maximumDirect { + offset := uint32(opIndex) - maximumDirect + result.opPaths[op] = nestedTileColorOp{ + parent: &nestedTileColorOp{relOffsetBits: 12, relOffset: maximumDirect + offset/16}, + relOffsetBits: 4, + relOffset: offset % 16, + } + result.words = append(result.words, ControlWordOf(4, op.Type, op.Offset)) + } else { + result.opPaths[op] = nestedTileColorOp{parent: nil, relOffsetBits: 12, relOffset: uint32(opIndex)} + result.words = append(result.words, ControlWordOf(12, op.Type, op.Offset)) + } + } + + return result, nil +} + +type nestedTileColorOp struct { + parent *nestedTileColorOp + relOffsetBits uint + relOffset uint32 +} + +func (op nestedTileColorOp) writeTo(w *BitstreamWriter) { + if op.parent != nil { + op.parent.writeTo(w) + } + w.Write(op.relOffsetBits, op.relOffset) +} + +// ControlWordSequence is a finalized set of control words to reproduce a list of +// tile coloring operations. Based on this sequence, a bitstream can be created based +// on a selection of such coloring operations (i.e., per frame). +type ControlWordSequence struct { + // HTiles specifies the amount of horizontal tiles (= operations) a frame has. + // This number is relevant for skip operations. If 0, then no compression of skip-operations is done. + HTiles uint32 + + words []ControlWord + opPaths map[TileColorOp]nestedTileColorOp +} + +// ControlWords returns the list of low-level control words of the sequence. +func (seq ControlWordSequence) ControlWords() []ControlWord { + return seq.words +} + +// BitstreamFor returns the bitstream to reproduce the provided list of coloring operations +// from this sequence. +func (seq ControlWordSequence) BitstreamFor(ops []TileColorOp) ([]byte, error) { + var writer BitstreamWriter + writeOp := func(op TileColorOp) { + nested := seq.opPaths[op] + nested.writeTo(&writer) + } + pendingSkips := uint32(0) + writePendingSkips := func() { + for pendingSkips != 0 { + toSkip := pendingSkips + if toSkip >= 0x1F { + toSkip = 0x1E + } + writeOp(TileColorOp{Type: CtrlSkip}) + writer.Write(5, toSkip-1) + pendingSkips -= toSkip + } + } + writeLineSkip := func() { + if pendingSkips > 0 { + writeOp(TileColorOp{Type: CtrlSkip}) + writer.Write(5, 0x1F) + pendingSkips = 0 + } + } + hasWidthConfig := seq.HTiles > 0 + for opIndex, op := range ops { + atRowStart := hasWidthConfig && ((uint32(opIndex) % seq.HTiles) == 0) + if (pendingSkips > 0) && atRowStart { + writeLineSkip() + } + if op.Type == CtrlSkip { + pendingSkips++ + if !hasWidthConfig { + writePendingSkips() + } + } else { + writePendingSkips() + writeOp(op) + } + } + if pendingSkips > 0 { + writeLineSkip() + } + return writer.Buffer(), nil +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/ControlWordSequencer_test.go b/modding/hacked/ss1/content/movie/internal/compression/ControlWordSequencer_test.go new file mode 100644 index 0000000..85b13af --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/ControlWordSequencer_test.go @@ -0,0 +1,249 @@ +package compression_test + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" +) + +type ControlWordSequencerSuite struct { + suite.Suite + + sequencer compression.ControlWordSequencer + + sequenceErr error + sequence compression.ControlWordSequence + hTiles uint32 +} + +func TestControlWordSequencer(t *testing.T) { + suite.Run(t, new(ControlWordSequencerSuite)) +} + +func (suite *ControlWordSequencerSuite) SetupTest() { + suite.sequencer = compression.ControlWordSequencer{} + suite.sequenceErr = nil + suite.sequence = compression.ControlWordSequence{} + suite.hTiles = 0 +} + +func (suite *ControlWordSequencerSuite) TestEmptySequence() { + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldHaveALenOf(0) +} + +func (suite *ControlWordSequencerSuite) TestAddReturnsErrorOnWrongOffset() { + err := suite.sequencer.Add(compression.TileColorOp{Offset: compression.ControlWordParamLimit + 1}) + assert.NotNil(suite.T(), err, "error expected adding an operation with too high offset") +} + +func (suite *ControlWordSequencerSuite) TestSingleOperation() { + suite.givenRegisteredOperations(compression.TileColorOp{}) + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldHaveALenOf(1) +} + +func (suite *ControlWordSequencerSuite) TestDuplicatedOperationShouldResultInOneControlWord() { + suite.givenRegisteredOperations(compression.TileColorOp{}, compression.TileColorOp{}) + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldHaveALenOf(1) +} + +func (suite *ControlWordSequencerSuite) TestControlWordsAreOrderedByOperationFrequencyFirst() { + suite.givenRegisteredOperations( + compression.TileColorOp{Offset: 1}, + compression.TileColorOp{}, compression.TileColorOp{}) + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldBe(compression.ControlWordOf(12, 0, 0), compression.ControlWordOf(12, 0, 1)) +} + +func (suite *ControlWordSequencerSuite) TestControlWordsAreOrderedByOffsetSecond() { + suite.givenRegisteredOperations( + compression.TileColorOp{Offset: 1}, compression.TileColorOp{Offset: 1}, + compression.TileColorOp{}, compression.TileColorOp{}) + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldBe(compression.ControlWordOf(12, 0, 0), compression.ControlWordOf(12, 0, 1)) +} + +func (suite *ControlWordSequencerSuite) TestControlWordsAreOrderedByControlTypeThird() { + suite.givenRegisteredOperations( + compression.TileColorOp{Type: compression.CtrlColorTile16ColorsMasked}, + compression.TileColorOp{Type: compression.CtrlColorTile16ColorsMasked}, + compression.TileColorOp{}, compression.TileColorOp{}) + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldBe( + compression.ControlWordOf(12, 0, 0), + compression.ControlWordOf(12, compression.CtrlColorTile16ColorsMasked, 0)) +} + +func (suite *ControlWordSequencerSuite) TestControlWordLongOffsetsWhenBitstreamSpaceExhausted_FirstTime() { + suite.givenSequencerLimitsOf(1, 0) + suite.givenRegisteredOperations( + compression.TileColorOp{Offset: 0}, + compression.TileColorOp{Offset: 1}, + compression.TileColorOp{Offset: 2}, + compression.TileColorOp{Offset: 3}) + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldBe( + compression.ControlWordOf(0, 0, 1), // first long offset jump to index 1 + compression.ControlWordOf(4, 0, 0), + compression.ControlWordOf(4, 0, 1), + compression.ControlWordOf(4, 0, 2), + compression.ControlWordOf(4, 0, 3)) +} + +func (suite *ControlWordSequencerSuite) TestControlWordLongOffsetsWhenBitstreamSpaceExhausted_SecondTime() { + suite.givenSequencerLimitsOf(3, 1) + suite.givenRegisteredOperations( + compression.TileColorOp{Offset: 0}, compression.TileColorOp{Offset: 1}, + compression.TileColorOp{Offset: 2}, compression.TileColorOp{Offset: 3}, + compression.TileColorOp{Offset: 4}, compression.TileColorOp{Offset: 5}, + compression.TileColorOp{Offset: 6}, compression.TileColorOp{Offset: 7}, + compression.TileColorOp{Offset: 8}, compression.TileColorOp{Offset: 9}, + compression.TileColorOp{Offset: 10}, compression.TileColorOp{Offset: 11}, + compression.TileColorOp{Offset: 12}, compression.TileColorOp{Offset: 13}, + compression.TileColorOp{Offset: 14}, compression.TileColorOp{Offset: 15}, + compression.TileColorOp{Offset: 16}, compression.TileColorOp{Offset: 17}) + suite.whenSequenceIsCreated() + suite.thenControlWordsShouldBe( + compression.ControlWordOf(12, 0, 0), + compression.ControlWordOf(0, 0, 3), // first long offset jump to index 3 + compression.ControlWordOf(0, 0, 19), // first long offset jump to index 19 + compression.ControlWordOf(4, 0, 1), compression.ControlWordOf(4, 0, 2), + compression.ControlWordOf(4, 0, 3), compression.ControlWordOf(4, 0, 4), + compression.ControlWordOf(4, 0, 5), compression.ControlWordOf(4, 0, 6), + compression.ControlWordOf(4, 0, 7), compression.ControlWordOf(4, 0, 8), + compression.ControlWordOf(4, 0, 9), compression.ControlWordOf(4, 0, 10), + compression.ControlWordOf(4, 0, 11), compression.ControlWordOf(4, 0, 12), + compression.ControlWordOf(4, 0, 13), compression.ControlWordOf(4, 0, 14), + compression.ControlWordOf(4, 0, 15), compression.ControlWordOf(4, 0, 16), + compression.ControlWordOf(4, 0, 17)) +} + +type bitstreamExpectation struct { + bits int + value uint32 +} + +func bits12(value uint32) bitstreamExpectation { + return bitstreamExpectation{bits: 12, value: value} +} + +func bits5(value uint32) bitstreamExpectation { + return bitstreamExpectation{bits: 5, value: value} +} + +func bits4(value uint32) bitstreamExpectation { + return bitstreamExpectation{bits: 4, value: value} +} + +func (suite *ControlWordSequencerSuite) TestBitstreamForSimpleSequence() { + ops := []compression.TileColorOp{{Offset: 1}, {}, {}} + suite.givenRegisteredOperations(ops...) + suite.whenSequenceIsCreated() + suite.thenBitstreamShouldBeFor(ops, + []bitstreamExpectation{bits12(1), bits12(0), bits12(0)}) +} + +func (suite *ControlWordSequencerSuite) TestBitstreamForExtendedSequence() { + suite.givenSequencerLimitsOf(2, 1) + suite.givenRegisteredOperations( + compression.TileColorOp{Offset: 0}, compression.TileColorOp{Offset: 1}, + compression.TileColorOp{Offset: 2}, compression.TileColorOp{Offset: 3}, + compression.TileColorOp{Offset: 4}, compression.TileColorOp{Offset: 5}, + compression.TileColorOp{Offset: 6}, compression.TileColorOp{Offset: 7}, + compression.TileColorOp{Offset: 8}, compression.TileColorOp{Offset: 9}, + compression.TileColorOp{Offset: 10}, compression.TileColorOp{Offset: 11}, + compression.TileColorOp{Offset: 12}, compression.TileColorOp{Offset: 13}, + compression.TileColorOp{Offset: 14}, compression.TileColorOp{Offset: 15}, + compression.TileColorOp{Offset: 16}) + suite.whenSequenceIsCreated() + suite.thenBitstreamShouldBeFor([]compression.TileColorOp{{Offset: 16}}, + []bitstreamExpectation{bits12(1), bits4(15)}) +} + +func (suite *ControlWordSequencerSuite) TestSkipOperation_Single() { + suite.givenHorizontalTileCountOf(3) + ops := []compression.TileColorOp{{Offset: 0}, {Type: compression.CtrlSkip}, {Offset: 1}} + suite.givenRegisteredOperations(ops...) + suite.whenSequenceIsCreated() + suite.thenBitstreamShouldBeFor(ops, + []bitstreamExpectation{bits12(0), bits12(1), bits5(0), bits12(2)}) +} + +func (suite *ControlWordSequencerSuite) TestSkipOperation_Double() { + suite.givenHorizontalTileCountOf(4) + ops := []compression.TileColorOp{ + {Offset: 0}, {Type: compression.CtrlSkip}, + {Type: compression.CtrlSkip}, {Offset: 1}} + suite.givenRegisteredOperations(ops...) + suite.whenSequenceIsCreated() + suite.thenBitstreamShouldBeFor(ops, + []bitstreamExpectation{bits12(1), bits12(0), bits5(1), bits12(2)}) +} + +func (suite *ControlWordSequencerSuite) TestSkipOperation_EndOfLine() { + suite.givenHorizontalTileCountOf(2) + ops := []compression.TileColorOp{ + {Offset: 0}, {Type: compression.CtrlSkip}, + {Offset: 1}, {Offset: 2}} + suite.givenRegisteredOperations(ops...) + suite.whenSequenceIsCreated() + suite.thenBitstreamShouldBeFor(ops, + []bitstreamExpectation{bits12(0), bits12(1), bits5(0x1F), bits12(2), bits12(3)}) +} + +func (suite *ControlWordSequencerSuite) givenSequencerLimitsOf(bitstream uint32, direct uint32) { + suite.sequencer.BitstreamIndexLimit = bitstream + suite.sequencer.DirectIndexLimit = direct +} + +func (suite *ControlWordSequencerSuite) givenHorizontalTileCountOf(value uint32) { + suite.hTiles = value +} + +func (suite *ControlWordSequencerSuite) givenRegisteredOperations(ops ...compression.TileColorOp) { + suite.T().Helper() + for index, op := range ops { + err := suite.sequencer.Add(op) + require.Nil(suite.T(), err, "no error expected adding operation %v", index) + } +} + +func (suite *ControlWordSequencerSuite) whenSequenceIsCreated() { + suite.sequence, suite.sequenceErr = suite.sequencer.Sequence() + suite.sequence.HTiles = suite.hTiles +} + +func (suite *ControlWordSequencerSuite) thenControlWordsShouldHaveALenOf(expected int) { + suite.T().Helper() + require.Nil(suite.T(), suite.sequenceErr, "no error expected in this verification") + words := suite.sequence.ControlWords() + assert.Equal(suite.T(), expected, len(words)) +} + +func (suite *ControlWordSequencerSuite) thenControlWordsShouldBe(expected ...compression.ControlWord) { + suite.T().Helper() + require.Nil(suite.T(), suite.sequenceErr, "no error expected in this verification") + words := suite.sequence.ControlWords() + assert.Equal(suite.T(), len(expected), len(words), "length mismatch") + assert.Equal(suite.T(), expected, words, "words mismatch") +} + +func (suite *ControlWordSequencerSuite) thenBitstreamShouldBeFor(ops []compression.TileColorOp, expected []bitstreamExpectation) { + suite.T().Helper() + require.Nil(suite.T(), suite.sequenceErr, "no error expected in this verification") + data, err := suite.sequence.BitstreamFor(ops) + require.Nil(suite.T(), err, "no error expected extracting bitstream") + bitstream := compression.NewBitstreamReader(data) + for index, exp := range expected { + value := bitstream.Read(exp.bits) + assert.Equal(suite.T(), exp.value, value, "Value mismatch at index %v - bitstream is 0x%v", index, hex.EncodeToString(data)) + bitstream.Advance(exp.bits) + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/ControlWord_test.go b/modding/hacked/ss1/content/movie/internal/compression/ControlWord_test.go new file mode 100644 index 0000000..734ad55 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/ControlWord_test.go @@ -0,0 +1,57 @@ +package compression_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" +) + +func TestControlWordPackedReturnsPackedControlWord(t *testing.T) { + word := compression.ControlWord(0x00FFAA55) + + assert.Equal(t, compression.PackedControlWord(0x80FFAA55), word.Packed(128)) +} + +func TestControlWordPackedClearsHighestByteBeforeSettingCount(t *testing.T) { + word := compression.ControlWord(0x55FFAA55) + + assert.Equal(t, compression.PackedControlWord(0xC0FFAA55), word.Packed(0xC0)) +} + +func TestControlWordCountReturnsBits20To23(t *testing.T) { + word := compression.ControlWord(0x00F00000) + + assert.Equal(t, 15, word.Count()) +} + +func TestControlWordIsLongOffsetReturnsTrueForCount0(t *testing.T) { + word := compression.ControlWord(0x00000000) + + assert.True(t, word.IsLongOffset()) +} + +func TestControlWordIsLongOffsetReturnsFalseForCountGreater0(t *testing.T) { + word := compression.ControlWord(0x00300000) + + assert.False(t, word.IsLongOffset()) +} + +func TestControlWordLongOffsetReturnsBits00To19(t *testing.T) { + word := compression.ControlWord(0xFFFA6665) + + assert.Equal(t, uint32(0xA6665), word.LongOffset()) +} + +func TestControlWordParameterReturnsBits00To16(t *testing.T) { + word := compression.ControlWord(0xFFFF1665) + + assert.Equal(t, uint32(0x11665), word.Parameter()) +} + +func TestControlWordTypeReturnsBits17To19(t *testing.T) { + word := compression.ControlWord(0xFFFAFFFF) + + assert.Equal(t, compression.ControlType(5), word.Type()) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/Errors.go b/modding/hacked/ss1/content/movie/internal/compression/Errors.go new file mode 100644 index 0000000..9d0fae5 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/Errors.go @@ -0,0 +1,45 @@ +package compression + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1" +) + +const ( + // ErrFormat indicates errors in the compression format. + ErrFormat ss1.StringError = "format error" + + errCannotRepeatWordOnFirstTileOfRow ss1.StringError = "cannot repeat word on first tile of row" + errUnknownControl ss1.StringError = "found unknown control" + errTooManyLongOffsets ss1.StringError = "too many long offsets" + errInvalidByteCount ss1.StringError = "invalid byte count" + errInvalidFrameSize ss1.StringError = "invalid frame size" +) + +type tooManyWordsError struct { + Total uint32 + MaximumDirect uint32 + BitstreamIndexLimit uint32 +} + +func (err tooManyWordsError) Error() string { + return fmt.Sprintf("too many words: total=%v, maxDirect=%v, indexLimit=%v", err.Total, err.MaximumDirect, err.BitstreamIndexLimit) +} + +type wordIndexOutOfRangeError struct { + Index uint32 + Available uint32 +} + +func (err wordIndexOutOfRangeError) Error() string { + return fmt.Sprintf("control word index out of range: %v/%v", err.Index, err.Available) +} + +type paletteLookupTooBigError struct { + Size int +} + +func (err paletteLookupTooBigError) Error() string { + return fmt.Sprintf("palette lookup is too big: %vB", err.Size) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/FrameDecoder.go b/modding/hacked/ss1/content/movie/internal/compression/FrameDecoder.go new file mode 100644 index 0000000..c9fa455 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/FrameDecoder.go @@ -0,0 +1,123 @@ +package compression + +// FrameDecoder is for decoding compressed frames with the help of data streams. +// A new instance of a decoder is created with a FrameDecoderBuilder. +type FrameDecoder struct { + horizontalTiles int + verticalTiles int + + colorer TileColorFunction + + paletteLookupList []byte + controlWords []ControlWord +} + +func newFrameDecoder(builder *FrameDecoderBuilder) *FrameDecoder { + listLen := len(builder.paletteLookupList) + decoder := &FrameDecoder{ + horizontalTiles: builder.width / TileSideLength, + verticalTiles: builder.height / TileSideLength, + colorer: builder.colorer, + paletteLookupList: make([]byte, listLen+16), + controlWords: builder.controlWords, + } + + copy(decoder.paletteLookupList[:listLen], builder.paletteLookupList) + + return decoder +} + +// Decode reads the provided streams to paint a frame. +func (decoder *FrameDecoder) Decode(bitstreamData []byte, maskstreamData []byte) error { + bitstream := NewBitstreamReader(bitstreamData) + maskstream := NewMaskstreamReader(maskstreamData) + + for vTile := 0; vTile < decoder.verticalTiles && !bitstream.Exhausted(); vTile++ { + lastControl := ControlWordOf(0, CtrlUnknown, 0) + for hTile := 0; hTile < decoder.horizontalTiles && !bitstream.Exhausted(); hTile++ { + control, err := decoder.readNextControlWord(bitstream) + if err != nil { + return err + } + + if control.Type() == CtrlRepeatPrevious { + if hTile == 0 { + return errCannotRepeatWordOnFirstTileOfRow + } + control = lastControl + } + if control.Type() == CtrlUnknown { + return errUnknownControl + } + + if control.Type() == CtrlSkip { + skipCount := bitstream.Read(5) + bitstream.Advance(5) + if skipCount == 0x1F { + hTile = decoder.horizontalTiles + } else { + hTile += int(skipCount) + } + } else { + decoder.colorTile(hTile, vTile, control, maskstream) + } + + lastControl = control + } + } + return nil +} + +func (decoder *FrameDecoder) readNextControlWord(bitstream *BitstreamReader) (ControlWord, error) { + availableWords := uint32(len(decoder.controlWords)) + controlIndex := bitstream.Read(12) + if controlIndex > availableWords { + return ControlWordOf(0, CtrlUnknown, 0), + wordIndexOutOfRangeError{ + Index: controlIndex, + Available: availableWords, + } + } + + control := decoder.controlWords[controlIndex] + if control.IsLongOffset() { + longOffsetCount := 0 + bitstream.Advance(8) + for control.IsLongOffset() { + longOffsetCount++ + if longOffsetCount > 100 { + return ControlWordOf(0, CtrlUnknown, 0), errTooManyLongOffsets + } + bitstream.Advance(4) + offset := bitstream.Read(4) + controlIndex = control.LongOffset() + offset + if controlIndex > availableWords { + return ControlWordOf(0, CtrlUnknown, 0), + wordIndexOutOfRangeError{ + Index: controlIndex, + Available: availableWords, + } + } + control = decoder.controlWords[controlIndex] + } + } + bitstream.Advance(control.Count()) + return control, nil +} + +func (decoder *FrameDecoder) colorTile(hTile, vTile int, control ControlWord, maskstream *MaskstreamReader) { + param := control.Parameter() + + switch control.Type() { + case CtrlColorTile2ColorsStatic: + decoder.colorer(hTile, vTile, []byte{byte(param & 0xFF), byte(param >> 8 & 0xFF)}, 0xAAAA, 1) + case CtrlColorTile2ColorsMasked: + decoder.colorer(hTile, vTile, []byte{byte(param & 0xFF), byte(param >> 8 & 0xFF)}, maskstream.Read(2), 1) + case CtrlColorTile4ColorsMasked: + decoder.colorer(hTile, vTile, decoder.paletteLookupList[param:param+4], maskstream.Read(4), 2) + case CtrlColorTile8ColorsMasked: + decoder.colorer(hTile, vTile, decoder.paletteLookupList[param:param+8], maskstream.Read(6), 3) + case CtrlColorTile16ColorsMasked: + decoder.colorer(hTile, vTile, decoder.paletteLookupList[param:param+16], maskstream.Read(8), 4) + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/FrameDecoderBuilder.go b/modding/hacked/ss1/content/movie/internal/compression/FrameDecoderBuilder.go new file mode 100644 index 0000000..aa501b6 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/FrameDecoderBuilder.go @@ -0,0 +1,41 @@ +package compression + +// FrameDecoderBuilder implements the builder pattern for creating new instances of FrameDecoder. +// This builder can be reused during the processing of a movie. Every Build() call creates a new instance. +type FrameDecoderBuilder struct { + width int + height int + + colorer TileColorFunction + + paletteLookupList []byte + controlWords []ControlWord +} + +// NewFrameDecoderBuilder returns a new instance of a builder with given initial values. +func NewFrameDecoderBuilder(width, height int) *FrameDecoderBuilder { + return &FrameDecoderBuilder{ + width: width, + height: height, + } +} + +// Build creates a new instance of a decoder with the most recent parameters. +func (builder *FrameDecoderBuilder) Build() *FrameDecoder { + return newFrameDecoder(builder) +} + +// ForStandardFrame registers the frame buffer and sets the standard coloring method. +func (builder *FrameDecoderBuilder) ForStandardFrame(frame []byte, stride int) { + builder.colorer = StandardTileColorer(frame, stride) +} + +// WithPaletteLookupList registers the new list. +func (builder *FrameDecoderBuilder) WithPaletteLookupList(list []byte) { + builder.paletteLookupList = list +} + +// WithControlWords registers the new word dictionary. +func (builder *FrameDecoderBuilder) WithControlWords(words []ControlWord) { + builder.controlWords = words +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/Maskstream.go b/modding/hacked/ss1/content/movie/internal/compression/Maskstream.go new file mode 100644 index 0000000..31988aa --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/Maskstream.go @@ -0,0 +1,59 @@ +package compression + +// MaskstreamWriter writes mask integers to a byte array. +type MaskstreamWriter struct { + Buffer []byte +} + +// Write adds the given amount of bytes from the mask to the buffer. +func (w *MaskstreamWriter) Write(byteCount uint, mask uint64) error { + if byteCount > 8 { + return errInvalidByteCount + } + if w.Buffer == nil { + w.Buffer = make([]byte, 0, 1024*32) + } + for b := uint(0); b < byteCount; b++ { + w.Buffer = append(w.Buffer, byte(mask>>(b*8))) + } + return nil +} + +// MaskstreamReader reads mask integers from a byte array. +type MaskstreamReader struct { + source []byte + curPos int +} + +// NewMaskstreamReader returns a new reader instance for given source. +func NewMaskstreamReader(source []byte) *MaskstreamReader { + return &MaskstreamReader{source: source} +} + +// Read returns a mask integer of given byte length from the current position. +// If the current position is at or beyond the available size, 0x00 is assumed for the missing bytes. +// +// Reading more than 8, or less than 0, bytes panics. +func (reader *MaskstreamReader) Read(bytes int) (value uint64) { + if bytes > 8 { + panic("Limit of byte count: 8") + } + if bytes < 0 { + panic("Minimum byte count: 0") + } + + for i := 0; i < bytes; i++ { + value |= reader.nextByte() << uint64(8*i) + } + + return +} + +func (reader *MaskstreamReader) nextByte() (value uint64) { + if reader.curPos < len(reader.source) { + value = uint64(reader.source[reader.curPos]) + reader.curPos++ + } + + return +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/Maskstream_test.go b/modding/hacked/ss1/content/movie/internal/compression/Maskstream_test.go new file mode 100644 index 0000000..707507f --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/Maskstream_test.go @@ -0,0 +1,62 @@ +package compression_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" + + "github.com/stretchr/testify/assert" +) + +func TestMaskstreamReaderReadPanicsForMoreThan8Bytes(t *testing.T) { + reader := compression.NewMaskstreamReader([]byte{}) + + assert.Panicsf(t, func() { reader.Read(9) }, "Limit of byte count: 8") +} + +func TestMaskstreamReaderReadPanicsForLessThan0Bytes(t *testing.T) { + reader := compression.NewMaskstreamReader([]byte{}) + + assert.Panicsf(t, func() { reader.Read(-1) }, "Minimum byte count: 0") +} + +func TestMaskstreamReaderReadReturnsValueOfRequestedByteSize(t *testing.T) { + reader := compression.NewMaskstreamReader([]byte{0xAF}) + + result := reader.Read(1) + + assert.Equal(t, uint64(0xAF), result) +} + +func TestMaskstreamReaderReadIntegerFromSourceInLittleEndianOrder(t *testing.T) { + reader := compression.NewMaskstreamReader([]byte{0x11, 0x22}) + + result := reader.Read(2) + + assert.Equal(t, uint64(0x2211), result) +} + +func TestMaskstreamReaderReadCanProvideUpTo64BitValues(t *testing.T) { + reader := compression.NewMaskstreamReader([]byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}) + + result := reader.Read(8) + + assert.Equal(t, uint64(0x8877665544332211), result) +} + +func TestMaskstreamReaderReadFillsMissingBytesWithZero(t *testing.T) { + reader := compression.NewMaskstreamReader([]byte{0xAA, 0xBB}) + + result := reader.Read(8) + + assert.Equal(t, uint64(0x00BBAA), result) +} + +func TestMaskstreamReaderReadAdvancesCurrentPosition(t *testing.T) { + reader := compression.NewMaskstreamReader([]byte{0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88}) + + reader.Read(2) + result := reader.Read(3) + + assert.Equal(t, uint64(0x554433), result) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/PackControlWords.go b/modding/hacked/ss1/content/movie/internal/compression/PackControlWords.go new file mode 100644 index 0000000..ef31702 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/PackControlWords.go @@ -0,0 +1,29 @@ +package compression + +import ( + "bytes" + "encoding/binary" +) + +// PackControlWords packs and encodes the given words into a byte stream. +func PackControlWords(words []ControlWord) []byte { + buf := bytes.NewBuffer(nil) + controlBytes := uint32(len(words) * bytesPerControlWord) + + _ = binary.Write(buf, binary.LittleEndian, controlBytes) + if controlBytes > 0 { + lastPacked := words[0].Packed(1) + + for _, word := range words[1:] { + if (lastPacked.Value() == word) && (lastPacked.Times() < 0xFF) { + lastPacked = word.Packed(lastPacked.Times() + 1) + } else { + _ = binary.Write(buf, binary.LittleEndian, uint32(lastPacked)) + lastPacked = word.Packed(1) + } + } + _ = binary.Write(buf, binary.LittleEndian, uint32(lastPacked)) + } + + return buf.Bytes() +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/PackControlWords_test.go b/modding/hacked/ss1/content/movie/internal/compression/PackControlWords_test.go new file mode 100644 index 0000000..745a20c --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/PackControlWords_test.go @@ -0,0 +1,72 @@ +package compression_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" +) + +func TestControlWordsEmptyArrayResultsInZeroCount(t *testing.T) { + data := compression.PackControlWords(nil) + + assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, data) +} + +func TestControlWordsCountIsInLengthTimesThree(t *testing.T) { + data := compression.PackControlWords([]compression.ControlWord{ + compression.ControlWord(0), + compression.ControlWord(1), + }) + + assert.Equal(t, []byte{0x06, 0x00, 0x00, 0x00}, data[:4]) +} + +func TestControlWordsSingleItem(t *testing.T) { + data := compression.PackControlWords([]compression.ControlWord{compression.ControlWord(0x00BBCCDD)}) + + assert.Equal(t, []byte{0x03, 0x00, 0x00, 0x00, 0xDD, 0xCC, 0xBB, 0x01}, data) +} + +func TestControlWordsMultiItemArray(t *testing.T) { + data := compression.PackControlWords([]compression.ControlWord{ + compression.ControlWord(0x00BBCCDD), + compression.ControlWord(0x00112233), + }) + + assert.Equal(t, []byte{0x06, 0x00, 0x00, 0x00, 0xDD, 0xCC, 0xBB, 0x01, 0x33, 0x22, 0x11, 0x01}, data) +} + +func TestControlWordsIdenticalWordsArePacked(t *testing.T) { + data := compression.PackControlWords([]compression.ControlWord{ + compression.ControlWord(0x00BBCCDD), + compression.ControlWord(0x00BBCCDD), + }) + + require.Equal(t, 8, len(data)) + assert.Equal(t, []byte{0xDD, 0xCC, 0xBB, 0x02}, data[4:8]) +} + +func TestControlWordsCountIsResetForFurtherWords(t *testing.T) { + data := compression.PackControlWords([]compression.ControlWord{ + compression.ControlWord(0x00BBCCDD), + compression.ControlWord(0x00BBCCDD), + compression.ControlWord(0x00112233), + }) + + require.Equal(t, 12, len(data)) + assert.Equal(t, []byte{0xDD, 0xCC, 0xBB, 0x02, 0x33, 0x22, 0x11, 0x01}, data[4:12]) +} + +func TestControlWordsMaximumCountIs255(t *testing.T) { + words := make([]compression.ControlWord, 260) + for i := 0; i < len(words); i++ { + words[i] = compression.ControlWord(0x00112233) + } + data := compression.PackControlWords(words) + + require.Equal(t, 12, len(data)) + assert.Equal(t, []byte{0x0C, 0x03, 0x00, 0x00, 0x33, 0x22, 0x11, 0xFF, 0x33, 0x22, 0x11, 0x05}, data) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/PackedControlWord.go b/modding/hacked/ss1/content/movie/internal/compression/PackedControlWord.go new file mode 100644 index 0000000..30496a8 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/PackedControlWord.go @@ -0,0 +1,14 @@ +package compression + +// PackedControlWord contains a ControlWord together with a count value of occurrences. +type PackedControlWord uint32 + +// Times returns the amount of occurrences of the contained control word. +func (packed PackedControlWord) Times() int { + return int((packed >> 24) & 0xFF) +} + +// Value returns the control word value. +func (packed PackedControlWord) Value() ControlWord { + return ControlWord(uint32(packed) & 0x00FFFFFF) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/PackedControlWord_test.go b/modding/hacked/ss1/content/movie/internal/compression/PackedControlWord_test.go new file mode 100644 index 0000000..c606f95 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/PackedControlWord_test.go @@ -0,0 +1,21 @@ +package compression_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" +) + +func TestPackedControlWordTimesReturnsHighestByte(t *testing.T) { + packed := compression.PackedControlWord(0xFF706050) + + assert.Equal(t, int(0xFF), packed.Times()) +} + +func TestPackedControlWordValueReturnsTheControlWord(t *testing.T) { + packed := compression.PackedControlWord(0xFF706050) + + assert.Equal(t, compression.ControlWord(0x00706050), packed.Value()) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/PaletteLookupGenerator.go b/modding/hacked/ss1/content/movie/internal/compression/PaletteLookupGenerator.go new file mode 100644 index 0000000..5dd4129 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/PaletteLookupGenerator.go @@ -0,0 +1,176 @@ +package compression + +import ( + "context" + "math/bits" + "sort" +) + +type paletteLookupEntry struct { + start int + size int +} + +// PaletteLookup is a dictionary of tile delta data, found in a palette buffer. +type PaletteLookup struct { + buffer []byte + entries map[TilePaletteKey]paletteLookupEntry +} + +// Buffer returns the underlying slice. +func (lookup *PaletteLookup) Buffer() []byte { + return lookup.buffer +} + +// Lookup finds the given tile again and returns the properties where and how to reproduce it. +func (lookup *PaletteLookup) Lookup(tile tileDelta) (index int, pal []byte, mask uint64) { + key := TilePaletteKeyFrom(tile[:]) + entry, inLookup := lookup.entries[key] + if inLookup { + index = entry.start + pal = lookup.buffer[entry.start : entry.start+entry.size] + } else { + pal = key.Buffer() + } + var mapped [256]byte + for mappedIndex := len(pal) - 1; mappedIndex >= 0; mappedIndex-- { + mapped[pal[mappedIndex]] = byte(mappedIndex) + } + bitSize := uint(bits.Len(uint(len(pal) - 1))) + for tileIndex := PixelPerTile - 1; tileIndex >= 0; tileIndex-- { + mask <<= bitSize + mask |= uint64(mapped[tile[tileIndex]]) + } + return +} + +// PaletteLookupGenerator creates palette lookups based on a set of registered tiles. +type PaletteLookupGenerator struct { + keyUses map[TilePaletteKey]int +} + +// Generate creates a lookup based on all currently registered tile deltas. +func (gen *PaletteLookupGenerator) Generate(ctx context.Context) (PaletteLookup, error) { + var lookup PaletteLookup + lookup.entries = make(map[TilePaletteKey]paletteLookupEntry) + + remainder := make(map[TilePaletteKey]struct{}) + for key := range gen.keyUses { + remainder[key] = struct{}{} + } + + type sizedEntry struct { + entries map[TilePaletteKey]paletteLookupEntry + lastOffset int + } + sizedEntries := make(map[int]*sizedEntry) + knownSizes := []int{4, 8, 16} + for _, size := range knownSizes { + sizedEntries[size] = &sizedEntry{ + entries: make(map[TilePaletteKey]paletteLookupEntry), + } + } + + toDeleteCache := make([]TilePaletteKey, 0, len(remainder)) + addToBuffer := func(data []byte) { + lookup.buffer = append(lookup.buffer, data...) + + newSize := len(lookup.buffer) + + for _, fitSize := range knownSizes { + fitLimit := newSize - fitSize + entry := sizedEntries[fitSize] + + // remove all entries beyond a certain limit. as these bytes don't change, retrying won't help. + toDelete := toDeleteCache[0:0] + limit := newSize - 16 - len(data) + for key, entry := range entry.entries { + if entry.start < limit { + toDelete = append(toDelete, key) + } + } + for _, key := range toDelete { + delete(entry.entries, key) + } + + // find any new keys + for start := entry.lastOffset; start < fitLimit; start++ { + tempKey := TilePaletteKeyFrom(lookup.buffer[start : start+fitSize]) + if _, existing := entry.entries[tempKey]; !existing { + entry.entries[tempKey] = paletteLookupEntry{ + start: start, + size: fitSize, + } + } + } + if fitLimit > 0 { + entry.lastOffset = fitLimit + } + } + } + + addEarlyEntry := func(key TilePaletteKey, limitSize int) bool { + for _, fitSize := range knownSizes { + if key.size <= fitSize && fitSize <= limitSize { + entry := sizedEntries[fitSize] + for tempKey, paletteEntry := range entry.entries { + if tempKey.Contains(&key) && (!key.HasColor(0x00) || (lookup.buffer[paletteEntry.start] == 0x00)) { + lookup.entries[key] = paletteEntry + return true + } + } + } + } + return false + } + + sizeLimitForSize := map[int]int{3: 4, 4: 8, 5: 8, 6: 8, 7: 8, 8: 8, 9: 16, 10: 16, 11: 16, 12: 16, 13: 16, 14: 16, 15: 16, 16: 16} + for size := PixelPerTile; (size > 2) && (ctx.Err() == nil); size-- { + keysInSize := make([]TilePaletteKey, 0, len(remainder)) + for key := range remainder { + if key.size == size { + keysInSize = append(keysInSize, key) + } + } + sort.Slice(keysInSize, func(a, b int) bool { return keysInSize[a].LessThan(&keysInSize[b]) }) + + // fmt.Printf("Working on key size %v, have %v sized, %v total remaining\n", size, len(keysInSize), len(remainder)) + for _, sizedKey := range keysInSize { + if ctx.Err() != nil { + return PaletteLookup{}, ctx.Err() + } + + { + var earlyRemoved []TilePaletteKey + for key := range remainder { + if addEarlyEntry(key, sizeLimitForSize[size]) { + earlyRemoved = append(earlyRemoved, key) + } + } + for _, key := range earlyRemoved { + delete(remainder, key) + } + } + if _, stillRemaining := remainder[sizedKey]; stillRemaining { + bytes := sizedKey.Buffer() + lookup.entries[sizedKey] = paletteLookupEntry{start: len(lookup.buffer), size: len(bytes)} + addToBuffer(bytes) + + delete(remainder, sizedKey) + } + } + } + + return lookup, ctx.Err() +} + +// Add registers a further delta to the generator. +func (gen *PaletteLookupGenerator) Add(delta tileDelta) { + key := TilePaletteKeyFrom(delta[:]) + if key.size > 2 { + if gen.keyUses == nil { + gen.keyUses = make(map[TilePaletteKey]int) + } + gen.keyUses[key]++ + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/SceneEncoder.go b/modding/hacked/ss1/content/movie/internal/compression/SceneEncoder.go new file mode 100644 index 0000000..0d33295 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/SceneEncoder.go @@ -0,0 +1,180 @@ +package compression + +import ( + "context" +) + +// EncodedFrame contains the streams of one compressed frame. +type EncodedFrame struct { + Bitstream []byte + Maskstream []byte +} + +type tileDelta [PixelPerTile]byte + +type frameDelta struct { + tiles []tileDelta +} + +// SceneEncoder encodes an entire scene of bitmaps sharing the same palette. +type SceneEncoder struct { + hTiles int + vTiles int + lineStride int + tileStride int + + lastFrame []byte + deltas []frameDelta +} + +// NewSceneEncoder returns a new instance. +func NewSceneEncoder(width, height int) *SceneEncoder { + e := &SceneEncoder{ + hTiles: width / TileSideLength, + vTiles: height / TileSideLength, + lineStride: width, + } + e.tileStride = e.lineStride * TileSideLength + e.lastFrame = make([]byte, e.vTiles*TileSideLength*e.lineStride) + return e +} + +// AddFrame registers a further frame to the scene. +func (e *SceneEncoder) AddFrame(frame []byte) error { + if len(frame) != len(e.lastFrame) { + return errInvalidFrameSize + } + var delta frameDelta + isFirstFrame := len(e.deltas) == 0 + vStart := 0 + for vTile := 0; vTile < e.vTiles; vTile++ { + tileStart := vStart + for hTile := 0; hTile < e.hTiles; hTile++ { + delta.tiles = append(delta.tiles, e.deltaTile(isFirstFrame, tileStart, frame)) + tileStart += TileSideLength + } + vStart += e.tileStride + } + e.deltas = append(e.deltas, delta) + copy(e.lastFrame, frame) + return nil +} + +func (e *SceneEncoder) deltaTile(isFirstFrame bool, offset int, frame []byte) tileDelta { + var delta tileDelta + for y := 0; y < TileSideLength; y++ { + start := offset + (y * e.lineStride) + for x := 0; x < TileSideLength; x++ { + pixel := frame[start+x] + if isFirstFrame || (pixel != e.lastFrame[start+x]) { + delta[y*TileSideLength+x] = pixel + } + } + } + return delta +} + +// Encode processes all the previously registered frames and creates the necessary components for decoding. +func (e *SceneEncoder) Encode(ctx context.Context) ( + words []ControlWord, paletteLookupBuffer []byte, frames []EncodedFrame, err error) { + var wordSequencer ControlWordSequencer + tileColorOpsPerFrame := make([][]TileColorOp, len(e.deltas)) + paletteLookup, err := e.createPaletteLookup(ctx) + if err != nil { + return + } + + paletteLookupBuffer = paletteLookup.Buffer() + if len(paletteLookupBuffer) > 0x1FFFF { + err = paletteLookupTooBigError{Size: len(paletteLookupBuffer)} + return + } + + frames = make([]EncodedFrame, len(e.deltas)) + for frameIndex := 0; frameIndex < len(e.deltas); frameIndex++ { + var maskstreamWriter MaskstreamWriter + outFrame := &frames[frameIndex] + delta := e.deltas[frameIndex] + + lastOp := TileColorOp{Type: CtrlUnknown} + for tileIndex, tile := range delta.tiles { + var op TileColorOp + paletteIndex, pal, mask := paletteLookup.Lookup(tile) + palSize := len(pal) + + switch { + case palSize == 1 && (pal[0] == 0x00): + op.Type = CtrlSkip + case palSize == 1: + op.Type = CtrlColorTile2ColorsStatic + op.Offset = uint32(pal[0])<<8 | uint32(pal[0]) + case palSize == 2 && mask == 0xAAAA && (pal[0] != 0x00) && (pal[1] != 0x00): + op.Type = CtrlColorTile2ColorsStatic + op.Offset = uint32(pal[1])<<8 | uint32(pal[0]) + case palSize == 2 && mask == 0x5555 && (pal[0] != 0x00) && (pal[1] != 0x00): + op.Type = CtrlColorTile2ColorsStatic + op.Offset = uint32(pal[0])<<8 | uint32(pal[1]) + case palSize <= 2: + op.Type = CtrlColorTile2ColorsMasked + if palSize == 2 { + op.Offset = uint32(pal[1]) + op.Offset <<= 8 + } + if palSize > 0 { + op.Offset |= uint32(pal[0]) + } + + _ = maskstreamWriter.Write(2, mask) + case palSize <= 4: + op.Type = CtrlColorTile4ColorsMasked + op.Offset = uint32(paletteIndex) + _ = maskstreamWriter.Write(4, mask) + case palSize <= 8: + op.Type = CtrlColorTile8ColorsMasked + op.Offset = uint32(paletteIndex) + _ = maskstreamWriter.Write(6, mask) + default: + op.Type = CtrlColorTile16ColorsMasked + op.Offset = uint32(paletteIndex) + _ = maskstreamWriter.Write(8, mask) + } + + if op.Type != CtrlSkip && (tileIndex%e.hTiles) != 0 && lastOp == op { + op = TileColorOp{Type: CtrlRepeatPrevious} + } else { + lastOp = op + } + + err = wordSequencer.Add(op) + if err != nil { + return nil, nil, nil, err + } + tileColorOpsPerFrame[frameIndex] = append(tileColorOpsPerFrame[frameIndex], op) + } + outFrame.Maskstream = maskstreamWriter.Buffer + } + + wordSequence, err := wordSequencer.Sequence() + if err != nil { + return nil, nil, nil, err + } + wordSequence.HTiles = uint32(e.hTiles) + words = wordSequence.ControlWords() + for frameIndex, ops := range tileColorOpsPerFrame { + frames[frameIndex].Bitstream, err = wordSequence.BitstreamFor(ops) + if err != nil { + return nil, nil, nil, err + } + } + return +} + +func (e *SceneEncoder) createPaletteLookup(ctx context.Context) (PaletteLookup, error) { + var paletteLookupGenerator PaletteLookupGenerator + for _, delta := range e.deltas { + for _, tile := range delta.tiles { + paletteLookupGenerator.Add(tile) + } + } + return paletteLookupGenerator.Generate(ctx) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/StandardTileColorer.go b/modding/hacked/ss1/content/movie/internal/compression/StandardTileColorer.go new file mode 100644 index 0000000..1d7b5f9 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/StandardTileColorer.go @@ -0,0 +1,22 @@ +package compression + +// StandardTileColorer returns a TileColorFunction for given frame buffer. +// +// This colorer iterates over all pixel of a tile. It sets the pixel value if the +// corresponding palette value is > 0x00. +func StandardTileColorer(frame []byte, stride int) TileColorFunction { + return func(hTile int, vTile int, lookupArray []byte, mask uint64, indexBitSize uint64) { + start := vTile*TileSideLength*stride + hTile*TileSideLength + singleMask := ^(^uint64(0) << indexBitSize) + + for i := 0; i < PixelPerTile; i++ { + pixelValue := lookupArray[(mask>>(indexBitSize*uint64(i)))&singleMask] + + if pixelValue != 0x00 { + offset := start + (i % TileSideLength) + stride*(i/TileSideLength) + + frame[offset] = pixelValue + } + } + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/StandardTileColorer_test.go b/modding/hacked/ss1/content/movie/internal/compression/StandardTileColorer_test.go new file mode 100644 index 0000000..318ad71 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/StandardTileColorer_test.go @@ -0,0 +1,104 @@ +package compression_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" +) + +type StandardTileColorerSuite struct { + suite.Suite + + buffer []byte + stride int + + colorer compression.TileColorFunction +} + +func TestStandardTileColorerSuite(t *testing.T) { + suite.Run(t, new(StandardTileColorerSuite)) +} + +func (suite *StandardTileColorerSuite) SetupTest() { + suite.buffer = make([]byte, compression.PixelPerTile*9) + suite.stride = compression.TileSideLength * 3 + + for i := 0; i < len(suite.buffer); i++ { + suite.buffer[i] = 0xDD + } + + suite.colorer = compression.StandardTileColorer(suite.buffer, suite.stride) +} + +func (suite *StandardTileColorerSuite) TestFunctionColorsAllPixelOfATile() { + suite.whenTileIsColored(1, 1, []byte{0xFF, 0x00}, 0, 1) + + suite.thenTileShouldBe(1, 1, []byte{0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}) +} + +func (suite *StandardTileColorerSuite) TestColoringATileLeavesPixelOfOthersAlone() { + suite.whenTileIsColored(1, 1, []byte{0xFF}, 0, 1) + + suite.thenTileShouldBeUntouched(0, 0) + suite.thenTileShouldBeUntouched(1, 0) + suite.thenTileShouldBeUntouched(2, 0) + suite.thenTileShouldBeUntouched(0, 1) + suite.thenTileShouldBeUntouched(2, 1) + suite.thenTileShouldBeUntouched(0, 2) + suite.thenTileShouldBeUntouched(1, 2) + suite.thenTileShouldBeUntouched(2, 2) +} + +func (suite *StandardTileColorerSuite) TestFunctionWorksWithMaximumIndexSize() { + suite.whenTileIsColored(1, 1, []byte{0x01, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}, + 0xFEDCBA9876543210, 4) + + suite.thenTileShouldBe(1, 1, []byte{0x01, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}) +} + +func (suite *StandardTileColorerSuite) TestFunctionSkipsZeroPaletteIndices() { + suite.givenTileIsFilled(1, 1, 0xFF) + + suite.whenTileIsColored(1, 1, []byte{0x33, 0x00}, 0xAAAA, 1) + + suite.thenTileShouldBe(1, 1, []byte{0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF, 0x33, 0xFF}) +} + +func (suite *StandardTileColorerSuite) givenTileIsFilled(hTile int, vTile int, initValue byte) { + suite.colorer(hTile, vTile, []byte{initValue, 0xEE}, 0x0000, 1) +} + +func (suite *StandardTileColorerSuite) whenTileIsColored(hTile int, vTile int, lookupArray []byte, mask uint64, indexBitSize uint64) { + suite.colorer(hTile, vTile, lookupArray, mask, indexBitSize) +} + +func (suite *StandardTileColorerSuite) thenTileShouldBe(hTile, vTile int, expected []byte) { + tile := suite.getTile(hTile, vTile) + + assert.Equal(suite.T(), expected, tile) +} + +func (suite *StandardTileColorerSuite) thenTileShouldBeUntouched(hTile, vTile int) { + expected := make([]byte, compression.PixelPerTile) + for i := 0; i < len(expected); i++ { + expected[i] = 0xDD + } + + suite.thenTileShouldBe(hTile, vTile, expected) +} + +func (suite *StandardTileColorerSuite) getTile(hTile, vTile int) []byte { + tileBuffer := make([]byte, compression.PixelPerTile) + start := vTile*compression.TileSideLength*suite.stride + hTile*compression.TileSideLength + + for i := 0; i < compression.TileSideLength; i++ { + outOffset := compression.TileSideLength * i + inOffset := start + suite.stride*i + copy(tileBuffer[outOffset:outOffset+compression.TileSideLength], suite.buffer[inOffset:inOffset+compression.TileSideLength]) + } + + return tileBuffer +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/TileColorFunction.go b/modding/hacked/ss1/content/movie/internal/compression/TileColorFunction.go new file mode 100644 index 0000000..f705541 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/TileColorFunction.go @@ -0,0 +1,6 @@ +package compression + +// TileColorFunction is one which shall color the pixel of a video frame tile using the provided values. +// mask is a packed field of 16 indices, each with a length of indexBitSize. These indices point into a +// value of lookupArray. +type TileColorFunction func(hTile int, vTile int, lookupArray []byte, mask uint64, indexBitSize uint64) diff --git a/modding/hacked/ss1/content/movie/internal/compression/TilePaletteKey.go b/modding/hacked/ss1/content/movie/internal/compression/TilePaletteKey.go new file mode 100644 index 0000000..86b3c6b --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/TilePaletteKey.go @@ -0,0 +1,70 @@ +package compression + +import "math/bits" + +// TilePaletteKey marks use of colors of a 256 palette. +type TilePaletteKey struct { + usedColors [4]uint64 + size int +} + +// TilePaletteKeyFrom returns a key for the given slice of colors. +func TilePaletteKeyFrom(colors []byte) TilePaletteKey { + var key TilePaletteKey + for _, c := range colors { + key.UseColor(c) + } + return key +} + +// Buffer returns the color indices marked in use by this key. +func (key *TilePaletteKey) Buffer() []byte { + result := make([]byte, 0, key.size) + for i := 0; i < 256; i++ { + if key.HasColor(byte(i)) { + result = append(result, byte(i)) + } + } + return result +} + +// UseColor marks the given color index in use. +func (key *TilePaletteKey) UseColor(index byte) { + if !key.HasColor(index) { + key.usedColors[index/64] |= 1 << uint(index%64) + key.size++ + } +} + +// HasColor returns true if the given color index is in use by this key. +func (key *TilePaletteKey) HasColor(index byte) bool { + return (key.usedColors[index/64] & (1 << uint(index%64))) != 0 +} + +// Contains returns true if this key is equal to, or a superset of, the given key. +func (key *TilePaletteKey) Contains(other *TilePaletteKey) bool { + return ((^key.usedColors[0] & other.usedColors[0]) == 0) && + ((^key.usedColors[1] & other.usedColors[1]) == 0) && + ((^key.usedColors[2] & other.usedColors[2]) == 0) && + ((^key.usedColors[3] & other.usedColors[3]) == 0) +} + +// Without returns a new key instance that has all remaining colors marked in use. +func (key *TilePaletteKey) Without(other *TilePaletteKey) TilePaletteKey { + var result TilePaletteKey + for i := 0; i < 4; i++ { + result.usedColors[i] = key.usedColors[i] & ^other.usedColors[i] + result.size += bits.OnesCount64(result.usedColors[i]) + } + return result +} + +// LessThan is a sorting function to order keys. +func (key *TilePaletteKey) LessThan(other *TilePaletteKey) bool { + for i := 0; i < 4; i++ { + if key.usedColors[i] < other.usedColors[i] { + return true + } + } + return false +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/TilePaletteKey_test.go b/modding/hacked/ss1/content/movie/internal/compression/TilePaletteKey_test.go new file mode 100644 index 0000000..e269475 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/TilePaletteKey_test.go @@ -0,0 +1,94 @@ +package compression_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" +) + +func TestTilePaletteKeyNullValueHasNoColorsSet(t *testing.T) { + var key compression.TilePaletteKey + for color := 0; color < 256; color++ { + assert.False(t, key.HasColor(byte(color)), "color set although should not %v", color) + } +} + +func TestTilePaletteKeyCanMarkSingleColors(t *testing.T) { + for color := 0; color < 256; color++ { + var key compression.TilePaletteKey + key.UseColor(byte(color)) + assert.True(t, key.HasColor(byte(color)), "color not set %v", color) + for otherColor := 0; otherColor < 256; otherColor++ { + if otherColor != color { + assert.False(t, key.HasColor(byte(otherColor)), + "color %v set although should not for %v", otherColor, color) + } + } + } +} + +func TestTilePaletteKeyCanMarkAllColors(t *testing.T) { + var key compression.TilePaletteKey + for color := 0; color < 256; color++ { + key.UseColor(byte(color)) + } + for color := 0; color < 256; color++ { + assert.True(t, key.HasColor(byte(color)), "should have color set %v", color) + } +} + +func TestTilePaletteKeyContains(t *testing.T) { + baseKey := compression.TilePaletteKeyFrom([]byte{1, 2, 3, 4}) + tt := []struct { + other []byte + expected bool + }{ + {other: []byte{1, 2, 3, 4}, expected: true}, + {other: []byte{2, 3, 4}, expected: true}, + {other: []byte{1, 2, 4}, expected: true}, + {other: []byte{4}, expected: true}, + {other: []byte{}, expected: true}, + {other: []byte{1, 2, 3, 4, 5}, expected: false}, + {other: []byte{3, 4, 5}, expected: false}, + {other: []byte{5}, expected: false}, + } + + for index, tc := range tt { + td := tc + t.Run(fmt.Sprintf("%v", index), func(t *testing.T) { + other := compression.TilePaletteKeyFrom(td.other) + result := baseKey.Contains(&other) + assert.Equal(t, result, td.expected) + }) + } +} + +func TestTilePaletteKeyWithout(t *testing.T) { + baseKey := compression.TilePaletteKeyFrom([]byte{1, 2, 3, 4}) + tt := []struct { + other []byte + expected []byte + }{ + {other: []byte{1, 2, 3, 4}, expected: []byte{}}, + {other: []byte{2, 3, 4}, expected: []byte{1}}, + {other: []byte{1, 2, 4}, expected: []byte{3}}, + {other: []byte{4}, expected: []byte{1, 2, 3}}, + {other: []byte{}, expected: []byte{1, 2, 3, 4}}, + {other: []byte{1, 2, 3, 4, 5}, expected: []byte{}}, + {other: []byte{3, 4, 5}, expected: []byte{1, 2}}, + {other: []byte{5}, expected: []byte{1, 2, 3, 4}}, + } + + for index, tc := range tt { + td := tc + t.Run(fmt.Sprintf("%v", index), func(t *testing.T) { + other := compression.TilePaletteKeyFrom(td.other) + result := baseKey.Without(&other) + expected := compression.TilePaletteKeyFrom(td.expected) + assert.Equal(t, result, expected, "Mismatch for %v", td.other) + }) + } +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/UnpackControlWords.go b/modding/hacked/ss1/content/movie/internal/compression/UnpackControlWords.go new file mode 100644 index 0000000..6cf7814 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/UnpackControlWords.go @@ -0,0 +1,46 @@ +package compression + +import ( + "bytes" + "encoding/binary" +) + +// UnpackControlWords reads from an encoded string of bytes a series of packed control words. +// If all is OK, the control words are returned. If the function returns an error, something +// could not be read/decoded properly. +func UnpackControlWords(data []byte) (words []ControlWord, err error) { + if len(data) < 4 { + return nil, ErrFormat + } + + var controlBytes uint32 + reader := bytes.NewReader(data) + + err = binary.Read(reader, binary.LittleEndian, &controlBytes) + if err != nil { + return + } + if controlBytes%bytesPerControlWord != 0 { + return nil, ErrFormat + } + wordCount := int(controlBytes / bytesPerControlWord) + unpacked := 0 + + words = make([]ControlWord, wordCount) + for unpacked < wordCount { + var packed PackedControlWord + err = binary.Read(reader, binary.LittleEndian, &packed) + if err != nil { + return + } + times := packed.Times() + if times > len(words)-unpacked { + return nil, ErrFormat + } + for i := 0; i < times; i++ { + words[unpacked+i] = packed.Value() + } + unpacked += times + } + return +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/UnpackControlWords_test.go b/modding/hacked/ss1/content/movie/internal/compression/UnpackControlWords_test.go new file mode 100644 index 0000000..aa8539f --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/UnpackControlWords_test.go @@ -0,0 +1,55 @@ +package compression_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/compression" +) + +func TestUnpackControlWordsFormatErrorForEmptyArray(t *testing.T) { + _, err := compression.UnpackControlWords(nil) + + assert.Equal(t, compression.ErrFormat, err) +} + +func TestUnpackControlWordsFormatErrorForTooSmallSizeField(t *testing.T) { + _, err := compression.UnpackControlWords(make([]byte, 3)) + + assert.Equal(t, compression.ErrFormat, err) +} + +func TestUnpackControlWordsFormatErrorForSizeValueNotMultipleOfThree(t *testing.T) { + _, err := compression.UnpackControlWords([]byte{0x02, 0x00, 0x00, 0x00, 0xCC, 0xBB, 0xAA, 0x01}) + + assert.Equal(t, compression.ErrFormat, err) +} + +func TestUnpackControlWordsEmptyResultForNoEntries(t *testing.T) { + words, err := compression.UnpackControlWords([]byte{0x00, 0x00, 0x00, 0x00}) + + require.Nil(t, err) + assert.Equal(t, 0, len(words)) +} + +func TestUnpackControlWordsSingleEntry(t *testing.T) { + words, err := compression.UnpackControlWords([]byte{0x03, 0x00, 0x00, 0x00, 0xCC, 0xBB, 0xAA, 0x01}) + + require.Nil(t, err) + assert.Equal(t, []compression.ControlWord{compression.ControlWord(0xAABBCC)}, words) +} + +func TestUnpackControlWordsMultipleEntry(t *testing.T) { + words, err := compression.UnpackControlWords([]byte{0x09, 0x00, 0x00, 0x00, 0xCC, 0xBB, 0xAA, 0x02, 0x33, 0x22, 0x11, 0x01}) + + require.Nil(t, err) + assert.Equal(t, []compression.ControlWord{compression.ControlWord(0xAABBCC), compression.ControlWord(0xAABBCC), compression.ControlWord(0x112233)}, words) +} + +func TestUnpackControlWordsErrorForTooManyUnpacked(t *testing.T) { + _, err := compression.UnpackControlWords([]byte{0x03, 0x00, 0x00, 0x00, 0xCC, 0xBB, 0xAA, 0x02}) + + require.NotNil(t, err) +} diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/controlDict.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/controlDict.bin new file mode 100644 index 0000000000000000000000000000000000000000..e5f23f44693cfe350af25965f08f93dc389ef0f1 GIT binary patch literal 69488 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 UBKV42kJi8oQPRI?7GWLxe{eY=^8f$< literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame00_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame00_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..a87be1622e4dfe6376158eb83b3e8c233477c488 GIT binary patch literal 22868 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 z zWC;%`X`Vr|#LzUM`!S$yR&b@6ZP6_R1+v}}F`{O|uXsq+>!NAnr3h!W4MB16G0 zZaKkA=I$e&0te#;C5sn(2fL6BL2$>{f2oBIJFG_>0R52u0G?EmLhOL5%~lGs;pnW> ze##t;kw9w&<{vK6hvzZ#fHPx3P1PJ!Wv4Tl6U>X?6Z0c&QL1xfbD1^FssOvCx}yhx zc(hTL0h6kMjQqsE`SWa%?2@%<0b}dG?2{A$vQP?0&nC_R6p#Z`F*dak-q=sc{); zeD5=(nb)9SGqEmRlbw^TW_3!O&hBTgGv`X@Gy8z%sd|VVSp`RnLj@UOIiQ8ery(x| zr)bnNwf*@W!=`Z1O0a7*vGw2fP(;B_*|LYM)J70fz5y?rhC$sBk8V9?v&skg#t^6CdV_^G0@Q_iKF8 zBH?(rylEmo(RV z>7EzPtOvo4X}2`*nt8s0)mhUzimXa7B^gPCu$Jm+{EW!tQk6`b?i*zrDUGQsu=p#w8y z6xcFLM||&UKRcjrH7}Z>{yO2^ze_o)LQL7r=Dl-YY`*%6vHd2-62tKZBl7q4=D&lB z%Sjk@g;?=%|91oGZt2jbn%#EOSyNyP*Aeb`Gj5bS7#u z5L()K_kRKhr{fhqP&V-r-ko^u^}Rl6?{GJ{n>%@yV8pVWJy5G3ncD*E2tiCavn*%k zCpK%FIH8fKj3IE%*MCS=eM=dnAtinABVB!8)8A0b?@Se)@Qn4J&*#OW#5^jQk`-fL zGkq6~z5*%kRFa=48+{m_i_J6{R5M(OtqpovEqbyFyw1wIja``4am}4h`ow;h!P_3> z--I?3shbxc#cTC6PU}TUzTRrAMrr_BE+yeW57Yco8S57C3)osV`%U;qt7$4<8raGQ?|h_v}+OiU+SF2 M%4-nRMi97v0HY*aLI3~& literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame01_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame01_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..4b1b2177d5faf2ba9f29bc667df80530b7513c22 GIT binary patch literal 11678 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 C>Z%a{ literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame02_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame02_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..63375872ba3c38e28c24ded975c12c95fbea9c33 GIT binary patch literal 4280 zcmV;p5J&HQeQ5f>Qtq^HJd#Nyxme1u+^=n``|Im5o_%5uB$7#;H@dl99f^_$C4+MJ z?xXwb>o1FT4(+xFlF%8`i_xWNBAQu_8{omhWco8QR>Ly~y0+WhKfb=QX?^i(hJ?y+ zoFdKu=|N`t(X^R<$G*>Yf62*dx)n<$KrvHPNH_P_)-TmF8#X>OD50lg@KKGQPzp@; zq)^qM8heS&uhyRJztqBNiR2NH{q^;aI}2&&X{W1CVp99NP2J}22QBR$jQR!hY?Da4 zvs>g={CX9}-{h&-K7`#&+^=vIU*BI?fBwKLsEw30rj{H)57Zm3b&nc^)D`#ZplBti zX92G5qy%^)^MG9Stzmow*0c9gFcJOr^^08(XWyHXZB0hn=!@k=u5Mp9H=CpIL8iyN zc;@QStN`6xCe5iQ0-qi>NPYeNAEYSZ&n5A&FvR=o8f0x?-ZP~op1&IacM2CEP1A2=~XkHfUW>tX)JXaob!65VX* zsW?~&J#1(9*VYvXJ40JTiX5+1;l#+%~5^1z?+-x;Mhv_*8*oVF#zXPPeeV818(;)jho80AKQUz&1( zAKzbCR?4UIO`%Smy*uH3&N}BF^O5XT{9>RW7}xKhF1|8TcDffGL(ad#hoXX3@9(dy zKIfd9zMZ;tL)wSUZO(Fb(OW}4~U*r`Fhr?>iS2W2yDN67YlwnXGuT#uR2#@9E=Nz{q^;Pj0uBk9p_6G$D|LmN+UDI z8}emW9ost&brNBF3A+k;rT{BCv<&A{qs@fMzrMb)(3N9Xv0dGNqMpa)D{?h5Z_xu* zfZ8*&F{4R0q~TJev#c*CBeZ?)e})V8{`&gES>1)RGKF=+TlM6riu$X*b-sc7_m0oM zdMo*pUl8hU&a*+XQ_i?sRzJSJu?}d|O8~E&?QcVd`a8G%Z{csCQ8FCvb@+mkrm?Y8 z(Hg!Se|>#q9S{4-7>GE-hi1Ll=L$4)s!9AmzP_=Nux#XmBLedl+2BMs!xJw z)WN~SgPO1oFb#~4@2{*x9AU;=`MqX4NOQ1l^U_#w)NBKlV;lSH>kz=$;Wi&jKReEL z7dtP=T9-B^YX(LEu@0kueSKu8?}uWGJH_zlXLGbRk&*#}j4}+5@2{*?9GWTZ|6c(< zbI;$|C&C*Ef}@NWWPg2qVyt1bm&#;MA+c(ZRQODF83w?-u%Uq;-(OhBHVfg;ZGb1~ z1j;)917N8a!zC~Te|>#pCw)R{>Dc9G*k;pzrMb)kP;ut zrJ?iU(1(;rG7h9*85j;Kh&*!xWRLH!tb3wVI)WG~jxp8{awvpR3upE!N`V-4$|DIM z-(OiOIKT!N0@i^!@Cau*%6q$&?2F6GC*VZx(h-@4Hj4W^hyU8Ob>0vZBYQ`$RzP__mb-#&Na0Nj^83@T% z6%h=?tNZKgDC!!#?cR3?fYPu92xJ>EL0keRVpV|F>LQpt$M@IP3b2fWQ5VkQ;2!Gm zu!4j#4Vr;Qddy0&X=25{zP_-2nvsXu`b&V*&gfvQ1sDi6O0aXfTCN~iwj5l4eSH}R z+nHPko3EYKzo+^`rki(a#iODzhZi5%4PYJWN%h{WB;Bj-)-4Sp!efRG zzw{cLwUD8^S~5a2Y??Ibb!5fI_8Lj`=!yKcO|O+}3` zC+##Rs{wq&i;@2oGFnmf+*|$IZ#)xr-@9}Ymv-yBclwbAs`|agk-K|Q8VWM*;55WD z)D3JBRe+{y_psvQ`I+kZFje*DyzriQUhWTf2#o|8?%}!Ng5vEghNiD^q;BH3ap0t6 z=Z~}3vAdA|7=-{~#mD^_fBxOc?&T|n!-}6>E2cXhLqsU<=rfh)k@LxU<$b$$O)VVq z&8CZqnYVRJ<^(kn?0@}Y#mE1a)FDzgb|O=ed11~8_0GKW6%re)9(lTK`rthCu6h4G zAD);MkrwXno}iiL?*W?FtmyWz&5ID?P)s@^M($bjo)BV)%njKT&H*AjF~ID^3gWU*!Ha} z6-P5FUohh1{_opY>fm-Aa<7*%kjTJ#Z>|Vep3A2^S*zvewQ14wdga*bya6}IZrz)} zJ>Vvt##^vmDoL8az0<_TICbm%t$}#6*mK{-6Jx6he8r1@-V5miq%d2}ycp4(#wj!Fh(GQG zTIazFVb71}%k$^9n^SY=(P`}$JD*SpjA*H@ZmxEmnf9E;&u7)|FPL#}`@g&m(Ymg;i8oF2?z#7hB@hvs zx*K!w8AifdEEu^T_lg%8Fw!-EDGYnXPR{z_UE-)wz#1e5iiuNz-H>9G^YD52d&RBd z-#;Zpy$Tiwp4?~~A`3P*(>m!^%%j3GLNwpc%ID^@*GX#zE=T?2s|_OKucFv6;|_v* zn%3h0D!zKJKR)BSBxA^E>#6hfIr?1vK7OY^S9t}lsi3{bo5+O|IuEONk!$a+xG!S1 zmSSrF^YyjYx%)O@m}~?uv2s85b`&6&LW!!d;p$G(7y6rf!6Cjc(p^0L?tccDzF(^X#N`s z-xw;xhUZ>@703m?Y@4{3XFwTH@N4t}luulTwKLP3sWonHfIHv#2G5>yvs}1%Z4o;M-mo)UGJbGqJN+*@Y0I< z1bqTsES&;v=GGAGq>LRm|0MiDgcwKg2n$>7D_=K2p5$h6u$GQ4NdCdE3ddF*8J(S` ztYl%sWy2e9hbW-(=Rn75z*`=H$3W)C;B}Aa9V%GpAF5&KA}WIDC3F(@v9_{qN3vk) zpMUGQyccWB=qUR_E>KV+81%n%6t|x&!(mDZgBK)!%VuqOj~ulQ60DePSZrE38Z*?> z2cZ9u7!R1gA}FEqE~a%9T?Jbof!Xf#7y1krC2ebT9J&sj2d;zO^hJ2+KCNXHkhAPP zy$AxHCCN?+rJs5x70mq*g{HiZLItNq34M!_KeGO!xu1F} zC87J#f|tmqEEv0+(QA^`Tl65r3($nT3AVfT?C=a_mI z-!A$V8SyJvPu|2u#K6&_Dx*&H&le<*^^#77w?gBgb8UP2BY?s+2s&X%0N!yADY}uS zgZO3a_xGW6e)M2JOL>FK*;D9XZF=-D`WXETg=IQucEh?FTVh+&-iEJ3Z=t==-$%OF zse>T$QfXpXZH{e^F`vDOmt78ka?*_i64{FvAHn|h2VSRMu|+D1U?xBZwzF+!xP9P~G zKq(_(X{f2BGSH+LKjq$JKjrjEdL-Qvjq8U*r{)C#g0}Bm)s^&1Iwk6px+mQfPKr-O zEAObN@n~SA=*th7eupkZ6>|_;FWAm@>Rls;i+;Jf0p5FrUDGq4%(8xR{Bxa|76u73itmJ+_I_ zRti~x(OKxN6kpL`QC3mUPi9#vJP9c_44sEXr{qW~<{+is`ieiNYgU1vj0K{_i;@3( zN&0xW>KEa=4AlcN-q{SW<-n`0cMS; z9PNN5dA=A47%A`rQE5ePPxWIr!(#ucI@CdWFj-R%yo`NgQ#;A7$&;rAX2yQ^gVaf# z%T-u&D#44ANgwyb!u@Onln?3}*=$41LiGa3Ib|MYFHU}~t?3+M3N*_mAc literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame02_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame02_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..b9e9cc300f739fe3eccf0c09d5e53466394419ba GIT binary patch literal 11782 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame03_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame03_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..3790588310380b307ce5620a4b3b29ad85de87cb GIT binary patch literal 11314 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 IBLI7!z~=Ra>Hq)$ literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame04_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame04_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..445e4427893bcbe72556913cdce75e578406b8d1 GIT binary patch literal 11760 zcmXYX2|yEf+V?Y)NtkfRBoK_E?IZ-`P#X^M>ec}Q5|1`Q5)gYt2oS*3h)S?^%YX<$ zu|nkX=+~g45VfM!Xtnzqt#&=Ai&m{IZI|w=yRF+IZMSM&O1>w4-#|i`{Ez4OJ?DRb zhx)G3JJ#5PDy>sB?#p5OoUtJQz_qmk1JFzWRn6_(G4Au#_(#kYT8BJ)NU*MWnE;r1 za0>7O5e_E-7>)-R_xpU*dQB0c2U7SDhxF9_$~ z$7vMKR`K<PK3+Ci=h*~xn zXbS4CmzpwY*@ngb*^|-`%rfXm7}kEoq9M2CIM}IAAF+jbdIZ1Re-GnOml>r$weu-9N{w zA)Evg;Kk$bW&(2BPH0J;L73eZBH#*pC6e0ONhFB8{jj$>*c1!>H|T9DF|>4K`+}zg z2Tw;er*kB8q4p!8ltCU`94`To2M@OZ3>~n3DSA`(%Y<{kqjpGIO|X8BX^K?iW^+XQ z7yS7nJC#SLGAc0(=|}|doStzK1m_YYFqX&@>6+B6#ECi!w-`jsdJ;iJ)sY!G0=db@ ze}WJ=kQBSEBYR1G*7$>X;RLq}R#n6Y0v*h#LBj$UE8yXo99K~&);R>eF^-^mUc|cH zefuAd?nYpt!~^@|fY<5UZx=3H*JIz&$WXTkou8h6052EN3LvBe@MCh8R1E^pGo$oo zv*ns6n%F^B$7zc+Q-e{^j9-W(q&KDV(}WW_c>Bny?;>4+BeJe^&~* zykeM-5CBPukQrdSy(awQLkoCq)Q&VEV`1DGBrF3Mo{H5yI^1fG>Z#naKX8LEKPr_} zDQHegp{PMkMnMD>AZ!GhKx=?tcoNX;q-%R};@U2V-L>t3DKVP060U&i$Y@L_#6ULV zEH8_~fgm-6ZIQI~_V%?KNRkxN5WZYpS7+hvukIBsb*hjGuYv{WPXjfiDVSlNct+!N zuSlRnRq~F*=h72SC_lDm%q)bkSPB#<0G?2TSPeRgmR2oLdUx}60D=h!Jt0bV3Y;ajXNcq{LXgFfp0z(+8sOJ{u1@$>ugPl`=2cl4#+Ic6A>krwp{* zT4epfspVqs#m;DnSra{NO@Og?{L1NL)@4H*!rzL{pIoyQldeTo6sD9tV>Q&qFXG^t%R|E>>%u-|Xu;dTi?A)`+&dvD=zN zW$_AYRpB)6I7o)kH@)`iryLo}3&LJNTLr&+@g#g?6Z6vcl%}ey(Es&S4>$G8mliAt z9UU9{&t{`#o3XzA?xG`8wP#_MabuD^9Uw_$p18iQzrl2+g_ghsAP6L=zBVP2T-4ZeJOYo~4Vszti_ z*7=^C2J5NUj%$AznrWzW-Xx>0wN?c=S17fbs63enj9rS1IeM@`Rq{HfB*(!!ezrDmk0ZgE$=+?Wyva`;lKllEjBsXY&KMvk|k1o zQj`>8$>M1l#fBczXE2OCSo}ZrivqplPl6}zCJ1KH+)HwRlc5s`7Bcjr45gjno2t9F zxDKR5M_!9?PJOaIrHip(cuf;!SkBfB%6~a$ow&mM_K}71=O!en;R3>#;Z3GNU8JXC z$g{w``sTFa$%l@~Bm2ijnjsW!h_Y|Uk93m_Cp$k`PN`uo;)mmy<$JTbQ_CB9*ljzJ z)9_6kOyFM{^ELgC^O96;TI_D@meR`uo*5>4&j=K~8Hu=bRE0R-76`PYp zTnx%CoCh!!0k17iThFg;iQ3@;j2wfYb4TU1SPrRnFD+Po%w<(g7l zeApaY6k8--FMaNRiF5*uKmcFEw**gRo9Z*clx+8%@{N;ZLKFo5>qqG8>HEH=t*z$P z!arS%nxzvQ(Rk~PCJTp{Gl}YzraxUh_)DDdUv*Vc|4s1ycW~jsg@vw@rxLW4#30^4jWOT7&e@U((SNN4U8^m6naI&0houP6A-y*{02<5lXN5TL z9Hg{bR4m>pO{P#3IUx4?DReOiG#u~GE^#%K002w-eKsON4(yA#R64^VyCBD094Db- zc@$?Tj^zb`z$N2%EanhkLwCWDVj7o`$D-rFmHbNvPVgtBjESUqq!6>4QIrW&N3Df+ z64pSjNQ-w_N*zd9(Bb%vJ{Bl+e}1kAdcP1LYClek;8@n5ogmE+L4zfd;V|F>SpT1p zTN>N))H{XZEO`{}OS}9vL<{X#lM>TK$i^Ut+i+mQZy5}uxO@oSh{0i_3e06r>pE}l zXNvX;@=ojww`Bf-yx@ge0?uMVV<1@MhQk{eVo(C;l#mM+H4=hOs#gsP9tF8u&I83x z|7+#{?niA=K%x*i4o4wHej#{qRWvcAoPr}Rzc`Q%T-6k=5I*-3X~Z3YYl$h$OmLKcFT5{@3<1RYx5NLv6o>B=(vX-85Vi?8 zm7ffW4H3lBoS#Dm^I3RaGEWIv01jMHB}IY&!s$_50=XCY7vOMD0gtj2;CFuQg9yRv z-vTQVKNT!41tv@!DO4m-h#Zo@zZ^~gDUPp07EJgV>Tx9MOALVsC2-*m9FMnQGaw%f zYX63R(Qpv3Oei{DQ0pL@ex?es9}YAq=iu6U976)%oOtHImW*M1p@u6Ba!npm;QkL{z^&+Ow2{AV}N|r3D@&Lkag{y{lFX>)(p_hXX@(*R2qcc`lCn${B-#(; z6?7>U3l?Iz62pkp{&qE8Uh0%VWVKrR&8YB>Q&DFgzR3M|ivQ5k-sn9|Cu98Jz9 zPi~Bl1^a<#h7Jz?c3XSvK4sua78Q|SOUS%_z4*=FuLYxx{;cf&&CJ);Vw&H)`S!)- zUdeK30w%;u#IXXjDBMm?FD&l6Dh z_~TbJfyDp6O%PMi4+O{b&rsO;^-;!!=HEEMbGRlB%+lk#_g+70fIsyhO#Gob=)>nY zB83yM$S=?)v@sIF$N|scNExh!La4{=3Fo5rcy{-P4O^Pi@@D5OZ%Sz`5$(-NGBXfQ zPD*U7FWGG5<|IoG?%G)giK&^TnPy<8+kf8yi(Ib^{~&rKeenE$hIp&+QEBzI+h>k^ zg`0*C6D*4x^`qHiWR^K<36wGJ!(r9{q|5KIfYreq$2z&NvTRe5Nr zIe{&j5zJ=+_vL`XG9F7C%=v-anriv7JXFHF>d_PA1ohPhS|TIgr~#%tw!HijUJ-a< zS-SsG{@X`xPfl-2t5!F-iX&&pD!v^@sxNR9Cvx-$2B8b*{K1mvyr^^@6F>^B9OD6n z%yE2|&nBD32>6i5N7t5sNw-e%@xCra6t&*%=s0aXPd}Y~6lL*VOWIU&s*klY(APrIMb$(|o9nmx8FC2cbVlCZG8? z9gL1{{5Y2_O|v!H>_lOTBeTj}Rkk9hXfJhY>HjFV+m>7quJUTZb$Fxj8|Ou9=?RLj z5D#g>;`E}foz(#)*=sgAgmu=>Q!Ry=bmWbkBR5Q0hEz*TUc{QtHI^tUS$KPp2^oZz z+NrdTWRXL(9Uf)NoLf-i%h1G{iK^TVao&)pBB(SuKUvO6iP|as(nH7Y-e zz>B6=IUKnP?G9ntR%i65SB7G&j(f^aMi59q}9L1e3w*mztVhu(yjAT zfSoW5GBvg|5YU{G1+)MX4b6`jiVyYz>J$VjZIWtXpgi;}8p2LH&6Mhu$wTj{IwgXB?UAUVlck z)|hQ1Th+=l;hUEqCB-CYDpj*9WtH1c+23C9^y%-7!V*?3Bqm9(757p=$?oNWE!`*meEK2MI$$T9P5;ok+w2nMItwzxY#U^@^;|cv3V2+pnlFv~p_RC(i*vgtCR0g9d5Ljv50O$*VsDzK)syn- zs**>Gr?LB5U#a={w9v#gMr@;q0YS|=Qz+x&8rLnGR&`T3x!^p(^QPhH3?|;d1#GoV z2;uR9Heb}3 z)uL5fJ+Vs{c~%ce&Y!HR7$UrJGk)mswOXPRr7FdO92ZZ{KXLcO(%nh*U%F4Ox0v&0OwXd}6>?zZ|T$@_kKg`TTwF3Hp8e1^Nu|*#(KKltI@6W(=fMJmwmH|H< zeli@K985?IfgQoifp~im2o;CfidX25==`&>BLi9gKH(|<@ST^QJ~gK=QO&RXD$5yD z5vBanH~zdWD|&;mI*UnGN}9H*$|je#%QeE}DDiNeJi!BkD4-*jbj<;=tB#Mg_jbss zT+ONVr9X1uxGZ3mF%jQ|M+_4@$Mf?(NV!Gi-`5cl!)PsN9M6#I0@md9=P3SQFo@}| zh~k7K3YZ{OJTfuhNG)IJDcybb>4OKi^YZq;8FlKbR419V#-8ag8q7&8k8XWCV=$tq zqVaSLU+#7vUoh#t zsaK=s9+y{ydg)&mVAv2^@1@VYl@`c=X& zY^V?-i^C;>If8Ft!wmp>F3SkDRkQ6vjjASOSUKrkU+A6Pd&^3NC7H`)Npi{dmP_AW z!0sSY{k<*lxt}(@^ju*&NeEDMB*h~HIZ>ES0YvO+1ZXWx@EZu42osDgbNv3{^^2N9 zRfe-FFRF@*l5?8H`EU=u~N~LEG>&p)9aEJ}o*qdQ-K!OR86A!#X zhtLRpX&v4?EMmDotD9$aNYC^|_sOzF^lU7k{7Rsy;DEvNU8q|glxTn;#M%V&E)#G- zx{~DTg@%$6=S;NwHl-HV z+Hv8ICj{;(wN7lkX`P(7CHhv}YdO;#MGmuE7h6@jBRZ|B(Lpav@!+`06_K8hAvX`9 z#zpR4>6y48uhsVk6qqEAFvoh{VNq2V2MU){Nd)qQQN&+p$aQ91`XU60a)vqAF%f{y z>jiklZ|;KGm$v|{L|CEK^LXPFzK-3h#P=ej7GT{OV^^~DZ zgH*8OY-e4rneCQj=2qv>CDl29Su^CgUu{p$h$c)*dHO7|8Ep1GSm5ggSP+Vx8vjqP ztJe~jJ}q6gSF-P5H%X{b)@!fxAP6mR_qsB9BUKt^kBP{L*BsPsXiOmr#f?~=Jj?iy zMj8pmLkb#R`~C@!1j|SaSIp-~d;pKc6FiF}sBnD4YwvdK3$``oNgASHQ+t(CqBQFp zyLL*8*3%>RX3n^KXOC)|b(wWCS2e9sne*VvIG+7FIw!3+R(N# zJYQ^ZRVEfMt1@>c$!MPb zs(__Po*Zb-De2P9F)y>FNc9K@fWj3+*d`hmBy9Br%|1XEno^5pS*?{_IlDUNH-%*p zrP=w(6r+q%YQi-uWsU0O=(R*~Kz4v4hiz1rCNE6tTGqL;v&}Z6Xrkxg(!Fa0acXo` z^X4KWm$E3)ur%BFCnUvm2~hwlMI)j^&kJ~@I375edbln&ud*bigwBdHP7)e)!q5AW zRvrRfFlTP6Df0frGXC0Yt>M|9x2+RzZ3!rr>Qad80D(@h$BPFhjK98PQT?@;3Hk#x z7lOVAtg8Z1fz-bp=KxV%M6Z>rW?6c#oyy&*_B;(-fT#|)HFNVIje}7L;cWrHLcXxNs)8DU%XMG{U!emvzozg@72x))i z!TUWI?JqY|U<}hOn)UlGlpx~A#^ADQ0!hHpY>{;c1}*5ExEGDqtq~XGq!8d^oU2%q-e z=gS3s$KQPTl4NGH{Opay(%fCiDcZ#&x-Ht=?Su33>g<7TCGvFAHj@@Cnoj`dj?qP-Rd4{kf_v*VW;488y#Y;mz^ zBX=iEy+Le0cdq6q$Gd%9TM`c6S^medqm}Uo3m4^=mzRf0>}xL%prC5IdwP1}#Bt*5 zj)V^lD~_F<%8`d9IamxrmVg5tdK44~i{Jr>MobbU_pFJ5v4W)Fa_=BBl?5!{u^e8N z{A0s=cfVBsYfYx>_~F;y-n8{)E-$<|ZJJ==mdvKI@(1%iFuic#^Q1lAko`;dN8YY| z+~qVh&Jty|?`>t;)tlJ{X59xXPc6K5DrNIWwn_Z-17TIsw5l_C=afC#ErEy5xA7R~ ziW{R!!ZidaGAepH=}w0!Juh~pt}mC~xt3n`R$+$f6O46W6k-92$DP9v0GEFZLdBPt z$3OVwFT|UI< z^Ln-V${EXTSf}SE_B?$MA77vTvTC6d;~UxHFZ=w*ya1ILyzr>TpMWm%-(Wd^8WW6i zVQ3EPBq1Hl3${KQc5!9`DGCEv)gSp5E%@vLPELmLv9XO&5N%4d)!W1qor^jq?2OpJ zoz95fH}62v-{g0O|2cE?-kVjrhV-;tO>S*YQ`jbR;QDos=Y4RKbiB9g__P%b?5XgN zX1SB4jm{2IO=gxD9P1=%C8@fyZ5#PUN@+@dVm@14t?SUo%$~e3mPsMkWfqqx+(g14 z365k5hBwK_ZQ-i+_Wk?yF)O?8E!vj%&5;|aHVGc_zYggS0;eK6N%4=D>pAj-IMs1)EE>n#oCTQ_*Xp zx6FOp!t7=Ctt)OS-npf(>(lbTR#!eu`0Ea-cCJoS<(Ql+&0;b)IHbxx=NxVMT*>T- z>DfB@-k_a}jq~4%OsSmJ7}%gKce`AB#ksX>BQj2II-}~Fve#(av2u>hP@SVG{P)T0 zf#-l7C?Q~^h7T7CDT)xdT-~+TOIA)Wh5j;m-|@J&Q)Bby1kACgF_Isz=M)qyiinsz zxhHq>d!Nr0y*@narGKGlM8LBk-~gsuP|Xi$5x}=T@D2NTw>tulwK;r=7U3DPj_|EM zr4l>O5dhCx@zBcyiq74OLNXx6FfV7>C^nXS%BYD%HLzG=UOuARG%2j0DxJp=CLt#((IJrP>19B=U?&VD?*Ps&Dq72V*deX+cMP1?fiJffe!I%&gJ?o?MtyFE)0J(3Oym5`( zZXfZ5Q=2yn3AD7wI*yEgcs(Q174d*kWo4-rA73B7E^J+_nN3V~G%r}NphllUnBZ=mZT zOqm+&g+1FAJYI1wPtV&$_Vxpb=|cH5GbT6i74i`T|81I#pC`Zoomza#6LL4PBaF|J z7*tGeg>#2UU;6g!K+E~A^!3b^9EZ7SZBrFj-CC7il`DuR6_d#vX->{R$45q@qwn8$ z5d`r*pG-OsvAaD*L)IIq7Fku%FVKyNn+g=M>h3N!yh>%5z~~nssHl6yPd8ExdR@*RK25?UXN(q~|5|yd z^T)qO9k&jhSPCZiQImuYFvyQO5I<}tq74Q-z2^a38{-FS4)N}M=S9}@l8=Lv!ONx zeZJY5_&nK&ueaCj-YjeHdFS_g-Jq+R{rhgUj}&60xpeq(kP}R z)3Go&MKb2QO~BAohX)2foy<9X`;LKW2+Iez8ecf204XxU@c$Z+NnyVhe)SsqzrpH# z<=0q3i`YvDNl?cjth9xy`?op-Y_U^cFbd-K8sC_ARlPUwo(BE&I#D=2WNa zBa3@DzO$<#-zHE=&Qz|fFW!?9kVpn&?{TDRWaQw%gPoG(f#$^I<01b&KJ6?4_K^Vx ziu`C6VVrA>%ZtU%PB(TzFkUe{ht`iZ{@*U$YQP%YNong(aKO)MH@Nvfdn@{v`5s=% zD#o({d|t6QK{b@+6c#Slq#Bqt(;Rd10vz_0z46+FDGqp165eFPhipG2iCBY1%28+w`ICLCRa8A=bp;=Nzty`geYWhR{r0fGy` zX4n--1rC^pG&w0$yG4~5gn5c27|MHje#zbm9p=T@iBF!GY-sR}-`B^P!aV!Fv;+ZV zBStQ-K$nXT-LTkSOSdPFjC6Lk_XGxt=b}ZMfakGYSar{FS>Ec%ci8HRq-I7ka2X`! zmfqexRcW|IU=a{Jui6!u` zxgfq5(%t=+4Tj$BKMNz=H5*60e;aM|{f7A)Q2m(htG{yL!la-+1f`B8mx?3 znlp9RmsbX^ptqYkD1AJ_B-7-gMUH_$GM%fL-Gjv&^4PPCAkz){UN^BUJn$RHY*Eqm zAzgex?o;{p(fsY`j%i18a#%0AVLf*#_r)F}^26kW4-t|Ci{;CM2l3_TDsMvlkeXFw zp7dUIsaEt249r&(Vf_LA?6j$cJ~uj11aDB=rB@ICaUE9HP2YU;?vBSb#Gv5F?QOs` z)4qU!QNhsh&-8jjG0F?isTA1F!UJwprElzKy);XTHs{T9&RhPC5ZwC^F>V{mQ#g9n z1sMyT@`N6&A1Gh|OpDOp7+Ss@`}e3(J7V8J;o30L30e*e|$;*Ec~wfM*< z0<2#+;2ad7Hq_QHIxz4FU1a)Cw>;)W{HiPF8Jjx>1_D{9GUiQRdtjg&^Q9Wa)H^GK zz%lN_K=@VE2B&3cFX#+^fZ;b^LjN+=kOva4kGb9Ri0768UNryMclhWFhyLSp?Q^C( zhZ?Lxxhq#GzLwuYf>^aPwf{s2wA5P!+9A;@ZF=BEja1d7(ph-yV;veRbSbcD`LKD@ z#msx@mF_L^alzob&iuM=q@={%OSPqIJNNVBqff71{q6ScoF5*JOjw-}@ccP^X7q8* z3keAom@AEk$5?O1e8yh><(;ZwAVjqw6T0&XD3m1IdUbOoqZvMbHdr#nkc2zo$;N&f4}#6pZ9s6=l4S(T*r+q%kJi4BNxQd^zLHpjJmPdkFXnLu+Xqr`d_5v zOHeM^^3nLMa1Op>ozsJZtg)S!ZCpabYaD#>-D`q!G0aea)^rD-U1aE9uMTb|nNW|3 zzfVznsrC4qk6WT{2wPlnnH@F{mwm;@zBQ`7oXMD{_rv{pN$~TAS5&+>IJ%G^W9CYJ zrDP9oj!bJD9oc01U0*oZ5KYcdIt$+J@Y8w%dyjYj8_y@aY6s0aOrLOtK14vU)#dN( zMXc!y-;wp|tmJq`hJ^<*V&@{j&Fx{~_x}pOMLZH5om5W> zq*Fy~L9z%c-ty1kRm`>fp}AYwcix6XAnXBU zn*S)bhFlep@FbacY04hTT`7wC)(YcNjGDr^6Z*Yd$$Y_ zkf*f$9NXDPbF=*3+cCHRyn2H3dKvx`9{1*hx1Xm`B%`63WZv6HtCrt5PCB!9WkG!7 z@|G9mG+L^)uCM1`Bko)5-}p@|dTs8dK9jIsYfnLg#P^Oiwkz@-Acd3(la^#gzy&Lg zUif>zT^iY=W^_;ngw-c(`zcrLwmglYbC6O=T3ixS zv(pj-j2p}tqnLNw-v&8eb2c;QwdVC70DLm~IjQUuI^Z8blB6dZl673q-A^RPx!lST zqgMfwnmbo3qgskv@zo9wY|Eul!tMH0aEDTtDQz1ETdwG7aMQWg7UWKX(pM-C^OlA9 zou%H8T{~`=@l-UOC5-99(hprab|ayAfnkbk-T`K?autXCFx5t@9o?F4A&XXA`aGt2 zF217{5MTW9hzjm#=2ozq(OUBQF_n773{tDSJ^TGNukKo~c?UPySGO{6BPXOV$b9Ju zc4UFdJ-W1RAn%YyHbKuQuKxDfR6Z^jZ({wcLREZuu^gMEEu96D9r%DZsadDfraIoL z^IC{0*E(7zgoEwz-Ko_Bn;AdGM$_rVoHMSYE$B*N_vV7I{{@eFM@Hwet#`30o}y!6 zC!;9s3KQeFowT-LYSYBX$_@Sdz|@G6V)C8>dp-44uoPu*nw6>=FBRHYi70{b@m?ge z-@qK`D0^HjVKZG{oARU=*8`#*X_$$)D9}wSg^1DQ?ME*6WstAnog&^B`L8u_7$V4Z8TCtBxzQCle zqeBcmtcL1N!FGL5a4$i*6{=QwrTgA|AFXn4xF@|~jcUuKqeR5FDM$TdM(SW=dR@5% z5DiRZ)C*p};_adgN(tjBTUH?q!%s!w<8?Q2yvxnQoQyJPjLY{TE9AP+n;hx0_^GQM z*)JBlp32ZiT`;-1(W5gm$i6RX+L$lv;J=O{DenZNTa z!u65$r`Fn=UF1|z&~W)6M1_dU7a6hNq@r7Ra!U-_4(iijMCovLcOA6<3lWg29l4)* zh)5j!1MnyEjA;gv8*a}Jo7G>+gw>te8&5FTJE|QAz6d$!($H2~-L^xM3Ctfi2ffG? z(?Ug%a_$Ih;9zxL6Q}C-doH-aGRE=V^RE62S%!aiT=)W^Gw-;dp9#lpCoFow zVY1hi1)-P5UkJb0`~yv%!&)qmtOTUdCvICW8<|xn&kN7gk;3i9t3wJ_A3xf>>>%-T zO4$JqR;RpXLF_g`R?j6ofScXv2)|10Nk{UDG?sh)41x+SJvG|hlKO8Z>a~*E7XX6- zVQqWaMN^n_JTIlxy7I3{g9?`6SgN+fMyPsH9~zdirFyRkvWX4@0hAtnQW@ck zHF35&*P>Vw{+`)6$nN?CK^Lonp7E2joRzBGTGZz^^e;Ve80aQ8>0yuKB09+bE<*<- zhRy0MnJ7X6GPS4id0{tf&&HIcFh1KVhn+IxH+)#s&`Oec*By(aLkcD7MkLeMu~^Y& z8-K-{^K9-H3jTLaAn-OP=fLM719vJBewfu&AHu+|)gRVf-MwHorX$ibpj6vm&w#{nJ;nxo*xM<9#+5@Nlo^Rkex*LsO<=*=2IsRk$7VqKvk9kG2<9kfxcK zXAF_`qGw;Wp@C%UQw?x`5mCLipAgKD{s@qFT2ou|X(gZ@{DJ}%xQjk(%4g>>&lh0Q z=EJnvgx(z#-8;O^(u!Ih12Jc>yb+!Xu%Gl#tz8IV7XW?V>iNoVz{<)UsImxV82&8G zlI{DakSA>RvInu#0hU&4D)hC3P^EpuNC#TQYXXnlJcNjy}ZwV(ZW5g zCz`w3!+!<$E6xX5;<>-vHv?{c3E}1m!d1LAPhRH&nX-mF`gaUzK zKF8D2XXM;&fd`^@a3^8)nAlB%2>^w7Z$!j(RkYyW5_`e0PN1{dGVq;lD6o*%id9a> zvVL){%9*>X6F6e=E}^OZ))ZaGm!|7|Y|ebVZ1CQ>)bo3_=S#|y!9!kRjs#I!zUmnp z4AP-1o*=w8g}}!U&VwB2wu$|L=RbD*HTwTO7hCtqArB4KmTl>j~4SMCLnf3VG=^TeF$p4P21*>O~9CQJavZ}|N$V`6{!+e4NyC9KVzUEAqp;?UuP z^zg+|d>V1M&FO?TjPD_9MYLs~^t4r8 zsM4IK6>go-mIsdy%(`5ii2}O-kY+UAOD)!luMa zp)WL%(H?yueEwYYt3Fjc8a{qTZemX6y`!ek7p~j>&!A)j1RcimcH}SinZ;QpQ-1vmqfl<;G3+Z0{X*&$1v#kBAdNu8WCa*B^pCcUrR6 zksf9qR?(xkDc&H64{Y7I4XU#nNc-wn{_40o z)*Im=0bkIg{{iv}xuFc++>CWqDHkcgkiv%75_)aYmnV>MRV4QN$d}*oACE&pN&IGJ ztJL<2E#k{)6njqc*!{vZDFMZqC@l3?Xq>(Eboj8ztEGU+nZ6g zByy$H&ex$n1xj9Kc;eeYx$G*W1}iK>V(IapKAYOV@%H?KXhM~U*vcv|n4S7H#Wu(L z20Y1F6YXCa<0;Fgb~Qh1aOe<|9*Q+Eb|nO9@jok2N-=T~>P+6sJOFzY$&F@tY&wfR zr##Z@)fG#VKT5s1`ZWUkcKO3!Yit7|T-)CCSvKq$iza6w0#nrf^v46@t?mK0p$@aW zWG*cPrnttVy$zC6q4HKR*unq6i5ohbXfwFxXo)LW^RDgSqs8Ee0_6z!31iNdEk&y){s^bmBz=mS^}6``Sh&EZXyKFE7kjCIJ^L~ZFION z;i?S$4KAa1*48&8O_3H9GLtT~xrrTPsxS{rq~9udZ!X9YDl7LX0gnaQjao{d(fd_8 zXd9jS|Ef8RZ?Ks4M7-fuBT&bcw8Yeuio$%ct>F7x9`tUi+^H#PtUK2x Y&F4Ryjbr^`85Jk;cAuUC1uD<}2RgNy#sB~S literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame05_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame05_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..f8ade5998870d6480beb848bba8c43044cfabb5a GIT binary patch literal 11814 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 SY4Z*<;VEumM+iv~F#iXspKojc literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame06_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame06_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..1b9a225aac07380f5b2d961d6875357c4d863c1d GIT binary patch literal 4238 zcmV;95OME)eP_2z>itW$fCrLEB$~@iR-v}l{q^;jP*1Eu1!S1D~U*BI@GaK{rhK*(0eS}zV z0)ox;XNAG!&Kr6}xNaOb6$V|-Y!5IRqFRJ;{q^;YaU}hi-=E+F@Z5wrD*y#1-%&wx z-p^oAVL|@~6$`n>6I4$ijz7M>v40C|X@O**04wLU^S7Vq1sA2}aMx?^$VI}G7^{ig zix%f!bFji{i*PWV^dTSLUsyf=qfLNCf;PSpeeI|!7Q5n%6$`rc_If9d(ch?Ldk1NM z=meB64bx!LZ3P%n1Y!J4w>xTcsfbx=zLqfH- zjm{hj+-wWehXgC(y^Z9aKP}9qs}snlbGC}UU>~}IhTylxPw%g+6opg8v{<-sXuWdo zb)|?`o*W=zTVnoJ=f7ucABPN?p|%Bv^0Y3|z901%+n)>IU?1t+`06L1jDLN7VH7^t z+hHQ*q(lbBbY9%~@ZvaeI(}Bn9n3u3IID~7R@hLs{~ww+_;Hr@>`5k0z&fTO+~40{ zSai!Hy(8j?;Jf-D`uh8LQ7r_P%tJQnlAUJJP{c=>z`XKO#@pJIRai*=`uf3A*HtNV z;qPlV_^Rg`E$HPyG*DtQ%*ERWbNgbJJ8UzDAC@;i*={TytzX|?SS#SQS#5{xb^Gl7%aXA@QU|B}9qIn{rCePXd&V#^!DoB00@aW#G%?z5I&*sJ)(@RbgB*xdr` zSLxpt8r*W_;m>>#{q^;V{&45g*9|?ff2cTgHoN+)L>4IFVD}j2kF94rY-Q+*wPii} zh+H}^g)pus_t(}Dm;@E&MNHOo2h-&(&_?JaJ^VU9WDE$kFuP9Rj}DU9p8XSQ;nL_z z{`&gI-ILH6KpVRMM+b}JZbh#CuGkqUC|O+0W=%Z}jsHf2-x3yxp$~KXKfb=O)*L90 zEx>DrziL_QH8d^D-9I=lxX4jl$=iMptZW8Ed^&G}RsHq#h=W^eJMD}d>D`f^eF)F^ zQE4rh4|K2o*Trma8UJef2Pr4_*VZz7C|}vEVyNQ+2C1Nf;LRIS+b-oE)4i1FHb)#5ecX)-jQa%v(i08F`EQaksp% z4T7+YBw!=^>+2A}*yU5wc{g+*UTNsloxL`$1PVKlg4cS6Sm(<%8aeaTzK`0zg$82E3a9T0g$NvG83;H;~I?3S} z2N(ztEg#=sSnHGtgE{lt&U^>TU?9jcMgft5JuVAa0K)+oj1TXxtYOy5u2DPK306U{ zRxn@@XR>^_!B9|-@2{+V&m6~A5^R5r(DZbLs5rnDwS&o4f&c{c>{X<(&ZCd-udJjQ zGgsWj1Y#Uu_{R>Y3wGV1MHZx(4V=wD;z<7b`o~r}ct3j2Kw6SdQ%Qg9%=X53b%0yB zq|&|EXpYV{0m{F=zOoI5qpKHt5|dpxNL7=_%)_hu>+3M&WA9_v!L`dKZP|%e_t(}+ zj6C{`_;tKG;M>zy3Nh}6g~Me04VtVXImNIP$o~5J!B!G5AcqdW9&y7?#$rG8fIK?| ze!*4+CZgf8c^Rh*FYm9cH6sR>J{^iJAkASfm-=jJJ%Ijj?6WsmE*-8|>$)3es}By( z|ExH-_t(M>O3j0PlI|U*#&ZS3+&fgj5NfZ(w!&`?+=Aii?y=h!KPHdVT+Cf}_8VB> z6YI@bgKt019l|YND#ByW4(GTycS)j$qgn5VcXP2p*YS@w=|(hR6BRHaW*)o!^#N@^ zkFNw{uEaVmW6tP(7Pd%ucbq%jIUoACU@L1u@b8Ka;oq`b38X(PB{7Gt|J~6_l6`s` z4RzJ-JK^AHNGc}}2Zx2j!{OovY5?o}@bP$f!K#H>1ygA*VlV_@#lO`EtTXHFMYO(E zgvXv9KMx~^k`zQ}rk>%26q}+B9#;=9_+$}lLgD0UuoT2B!`FYR5li-v`uUxG$9g*j zn&IYWtHaIwC;v#b)MhpZ}w=%a_P!`R{MsGZ^MX}}SHCQ9KMn!|^-lyean3q^+)AN>Jz z?koxHyjsIn04)0Iq}yLmR9tY0wf#sGaG%56;qLZ0d)s4Q!!2&}G3ep&F;taLhQ-%^ z^ab-2!>F}{z%~FZ`slae@QR9!3+19lJcSkZ;qhT(hsKd)$k_f0ze3_`>}c_3wuT0= z)Qo+1|3FEy2g}!B>LxB=v(n^HT(l_6bVRuN1uNW zvxnQS|3#i3a*2GCKz-4g2;69vkgA^nqH3IKH>e2ADw|qXL9itR!;9El9lF4v32GyKl;I-de}Z8x;SJM5%tIDFQCqEllXpoKS3VcJ;U|k`&@m)_u>3` z7M5O;Fdw}X&2autupgJP1?%DceE!Tr(O~Q1A-O5$Nj5e{(&A1eLq7M)3 z+*d>J{|x(w`@{Yyw}=6trw|8-1-3+{zLkLdW$9i&a6$}6k*h3ZSP<%yw`{ewoa|fne1FZejrB>Cx{iq3*rWAjHg7yt{#+)lNFSo zXP18a7|+3Wc!ApXq9$Y;1%LX%i|fbwp%4`9y&WrN95xIB%P--u7krNW_is=j2vX(F zh8#ftAcom(kJW3$6XFVOm*<1~pvq2DpQE&9aeeGV9o6m)Uzqx<8G@OoR}J z*fCfhlA|41D#O>s$MwCwxv{q`kt99Nl51OIE93=M1?7tky?&M2w7C)ne8ro9N4!D7 zY||4t1Miz-p}g=iqy``B>Uu-J&#ut!RM^9l18wsE#}o9_^*V>pF&JlR}A z%c8i2v{~CcHb)S@h+#vWMn){WzVakIq}9z1jt@ohXSduX#2=#EzQylC%^6H}VgKs~ zUl-SZ_<)pXMUXu`w$wq?Y$Rhv3^w6Lz(VwlG^)^QwkX#4ZxF|bW#s)=TtiMFu9*%Y zw-Ddn(}&_5ZJ=$St$LU|1Ib2wjeY2M#5*@2Fn%y(u&fa0t|-cdhKa;K;vjJlcG7iT(9d71h>65S z;v;nZ^<)(-3>0UyHZ{Lh+Y$Ma%zTsUuyLRNtQfi1e>M1&bcP@;rXi-IL+=1&Fm(=? zm>KCBKDS7WxhaQ1>~^K{@h>tT;v?zx;v{hrc!^tTy7ofm=p(kb#7#_^yhQFIe-T25 zy_>5b@n~SA&{GR-fVUVgVgpi~R&*n_J;}INX0Kt3uKsC@>S3gp4{?l`>nC6xy1~;i zl_ZiO#%e&LiSd~!pbIcnT}o3Ih{}IIy$|-;gdfCI0O3b=PP~78rx9C-uf$m5EbX>& z7Wj*unPjN(hLZ_lK_cD|KuEblPBH32PmyUBVXzd&9A91h(-v+h^!V!E;^Qz-&t(SM z+El)nIQ-$s7yyzGy#AD(zYA{uMW!aWbKd?zjgs?4wk0?wME^((BE3R^s%P9uHg`dqZBMu`zrIabU zPWgR)?VJfj-w~lFtz@j+p-ibHha{O&NL@qkVq4b~442)AbX^56E6gYG94Q5%?UwJ?zLdd z5s2oAAko4LQXnND`)?jTrbj+%~J@EdC2k@{XEva)_7#}Vg<_0#sV zkF09O6DMKZN8Qe7`pHDhANI~1I>C)d!;%h`6R5P{@TesC(im} z+1=oY8G%bXQsTME%xDyG-IrP&*@i4{?$Yj+Hs6GHZ~Y27R#vyMI#s1xC!c?~i=0Q> zpR=~XEbZ<2@cy6OH%&16S;yG?&evv5cEamdOh@T)E-bN$PO}l0uK!46tUI1ezS>lF z&Kce#_lk4*;y?N84}5^T!AU#DVQN|dT%aWv^PPi^><4=b@-7eEi~Y|&>AFWunwG@P zbeSZyxsWEuukJ7{bgFS675-*p1?go>KGsUS&dZINFdYO{l^M$s`FOmzkXk_*$x@iX zi<7SY&tFOyQgiW2{nyfp-2P^ekN6~E1tgv2ZMY@Y3YaO0b`^xd}fk!Ki6l^{pYRL@I#4`I+te(-N3<6&`t=ml}tajm#g00~*pS zNAD#+a&GB`sS7Z5<}OaV`#pVVo!v1gB2wpHLU>^|+&vRP@Fas|B7Kt)m;cJYrjTq- z@-bEq&8^H(vMEqPtxPuk+{P&$W^}mZ;tL9PQj~B9HiA@Ivf}^ga@0Y4C7A0t`o#9w zA(fEK#uc@s?q5#&!hTYPdNzYAIT?EM7uS;({?A`hg<8t7Z|W=kfSqYfCb1F>kY|2G zd5`kcRF5}EXQ}GaCBL8VKotA;Gh-b8)9_pStt+L+T3L02Do*63A3wd!@SECPw1sy5 kOEK1Q_FC8yPVaq88Re!vl`9eaL=DpELKbo`|Md$$fOA@L7XSbN literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame06_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame06_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..f5910502d0bc3b8ce15ce6b09f18485408498c55 GIT binary patch literal 11986 zcmXwf30xD`_WzlgER(QJ!Xkp!0m7=*u!ve)8CD5y5t2x?uSN(E&}iHMYu|?fS%P8> z5fryyqZNTzpVUfht9=HmKD9hceQL$Dul7A{tFKk;@7cwo^FQhT{~wr1GBfwybI8LpoUBW zl(Y;8-ot_Lhzgoi2S8_na|O`vKmVhJ5GY_1i}xpv1l4zP0N9TwDPnw`12+>d^~ogg z_(x5EQ4T;5p8c2fn^va=62OzI0I7Mfc{TPjP7wS!;aQcZi*fu3+c~Iy0yq{&RL!zW z8BnEzgkiG-0Oo{aTOOq8WG+KgPtZ(#N=YG@HROPKkFo{QtcK|zp-XxHMqUI!IQ{K7 zuFKVTnDCo+7HoDNn-d@+2?~c%^LH2qXG}A|2h3ebWgB0Or;++O8hq$G^7+bh{X7`G zv>Lb(5~9za_t1lrF7aE>iV~nbB%g#(@lGH(QAxf^6hYve34(^~sQ*8zJnP_L&VeKH z$Qd7Fcn*l}3t_>?Ac_}f8gIc0Iy6>>?0~)Rj`A+OuJ5NY84Ies5~&rABXJ~%u>E?; z+FcFW9Mx>c_`Y`dknkR{xDuj56Skb2IvfUUKTd`TA;I_wG=k^&o;8)IT@v3Pf$F`oNCACF~|%)jLTGefWHld2t zI0+6e*_=GEJ{_1;CkWVU9ft(**LN_N6$u2Zr8(3a7Pz+?2l>|qHFkawXbKu|NGr!V zXiPr-0X50w1gbrQ@=_ezM@!^cA1#uX@aGne%&|%SlDzbgX>ao;`n(pXXc4d? zPQo({wxZg(>&v5ZUJ~{CZ89_>sY==(-n4JoVr^Lxk{g_-v?tSv)pU8TEM)0=clMY7TD6u1MLqbmj50;?R)8G(hP2Ax};y!Si;`kd&0<;*iw&Buk)< zU_%foY2axtfDiN0`A)x_GKMqYPXXugEU}pq8iO0D>gg>p`ASx?GdzHp972eU@N!35 zjcR?n*{Lrgi^Ftr8)Hc|F~JkW$aqx0nHzGfq9xyMSor9or7q*_*|}^nqh-v&n!-3$ClS?V4ez$=*7{9`&Q!GV*iIUySl@t&g!}BMOa@ z&{B>Ao@vL4ubbY`y23hRUD3J?owwsI3zIt7*y@sa5aqW|J*8y5?gb8B*B)!x{Pg(} z5Plx_=HFL;DQO2@m7*5;1Dmf32ZrRp?wd(w1ADMu=LAxWf2&TuuV2hB1HP6T3LlJQq9&}gq4(0=vufu-Br$KP4G^x#2f zuQ22Dz3(l3#mWGt051 zc-4!Q4ol*Rf9GB6`0JBZ-n{J{Tx36(7D|eXjjPUP)YTC_EhUis9>|IqFkV_elCUIj ze@g7FG>v1*%;y=DP7ub1#f3S%#P-ABoL8>JJb3!XOU)u6)eN9r0XjpR2ll{$i1Ivs z+<$rZgx~fakM@6V`pm80SlyDQj3H{uY|uGs#-i@Nr*jCF$n^(S1*n0Ve@->9SaeM` zQXjB~(BIaFx`eX}R>xO=cXOtL57xP*AJsFVND2hbyfa3H; z4Nz$VIrO~82L@2B*K9fcO&FXFG4$HhU)rce3>$ufC)%(GA@2-I87r*|mFiB>I zK?ogDtt*}9_;gTQu6>aVeCa-egm~@%jD4N%mi@fOveJ9^?!5<_-)!IgOm*P;0<)Cg zA1%6j^LA>P!E_<1=tN_|-r-k@38|AHi7T!*3nhuBjVW~0o`@*-$Ckq)sL5@}Wg7OD z7B{HAy68^b5K~UrHUc*sl1`h$+4!`$Y-16K_20dweR1lhQ#(%PyncNmkR&Rsi05gW zJ+5rezV}LJf5hzJIGLhqXPl+KG_qttyTgB^?C%XiP|%!jPu!jN*E2Jk=7yW1ny_IY zrA1CEYuObsnQXDP}-D>oC>*2zWQ9=`rWzURPuBfqXPed<0r@1LB!&)nx0 zg1mDq+d5Ry#^a6y`OK_1h@&^=Gc*&%s(}|FCBWx9p}C)y7-x+iX$Ru&YgwF#Jt{oW zjS9^AqsKme!t1o0D>O8e7zC|4B!!Eo3^NHT5S0RK(Fu(u&d5{iX77IL(t||87TQ(i zx|+gT1!a$`+}GYn%{!sF8`z<7sCJ$dS|bK3d)^953+-5YsGb_{B%;x)@kG6y1V)K- z>F&^JuiCnIzx}phX_Y*&4?&wKd|5)&IWW8uB? z8od}ip)&bXaeag9;PSe5N!pNei2=Mn&>j60{0w@;y9M0G|+NsqZ z9_6;VEy3H}KOXMPd9GPZha=?lc;r@O&X(lU<9~~PK2m+x0o!kyw=)P}IfyL;vBX$h zT*{A*jSeFEtaI(^46Qr9BgmoBG;D2HXPD8L9o|IX7zhq2o`=0G15U)8Gfic?_e25w zSZAT~<8~um=(8jt1cb{W8U2qP_>)O+L8lhAiS1+{MTjxF0?EcoJ1zw!3>>?;|Gzuk zmSqmz!cLd#V0%b2PKl=&+6ll)K$*yDSo`EFy>3X0R;icchJrWjLWFlfCw77#<9MCY{vlx>}=#BIKv?yKNuck z5ac&vkiqyu!VvEdT4XRl5QBGE&?ck;jGIZ=7(|*PMv0({=6LMJh+b9-A&kTb2n{7X z1+Jwi7nR1r5$`ngTM`ic;D{a;2s;u1+#URd4SAm7#ji|=0wWKgIS4EpjPvLGeC`{8 z?xmPb#8d!%F%swg`>-5}SzDTITs7bk$%L|?3h}7`$I|dB-a;3};fEdPf%k%`Bp+jZ zdSP@VhKIC{Hc>+)oppg*CMG}(o zr3c7s96?Sa{=#=aD+BsG55UgHE;$w-OE8>c;G~fc_#!^C6CMVIgFgt?hjnS70J(`z zl&B9tFdP9PBJemN+=u4Tww;yRDi7CaM&m`6ve>iS*F` zA|#0)#GzEt$XpPq(J2t21*i&-CxsIs;Hk$siPXUIcYshL&6r}K3etoJ`vA2FpZbiB z9{vMN@W>py6QyZp0a9>Kf(hfmL!#5n=kZm1*R#WX$#zKzxs-vfrm+FdvWj{l%5%r* zn~uDer_X~35ui`R3=1sITLiV>C*ph~qKOy*#5h?G(rcjOHeqGt`o0`A7k0i=JKq zRseMqb{6SDSPP;%9MAvh`hwTa=LQage?M{`pK+8ORS@OJVVVZAV)g1d@u_K<;o`-T zzA_6b@dxYA*LPv^{@205!7Yn2GG5F}H!ZlSgXD_jb>YYfn9?7F+CtiRiT$hVxy$-R zQT^m>pgeMa4jlxCgUC;szj_dF3oZ`LGv>rF`F52o7%tLTBq>i#RVeNr?&wHgJ$vA* z+ZE;+%~4HEqex308%~F479CfkU4HE0aDZ}{;BAyogowKsX&mSPO~oI1z@s5F`fQlt z7$Y#YOUDdtp)yDMik(Ni8XMSZLt}R4fpLwnY_p1RIK}DZhH^vNI$Ck+&$rGV8}9Du zj(_mr`8xH6@KRKo#qO2fojQ#bO{|GhMFEk&bwf$dYO$XT!?+V@BWBSeEdc*AOOa}H zh&2D=_kn;W?vE6pel7p?x6eMgA0J-zwKA=!yvf!g4YB7(4s1vD6Qecs5AMJ6VVMfb ztJ8bOJ4AWsGaaO@EJSmpMxFCXdeI6$kG-Rlq@+@*Vcg;PZ8P=}{kN;8y1V!!vsQ{} z2Z|ic|22Z7ko7Fk9}p;R{6>cK6ZiP{{xF5{K3C1#(pMf&uPF7}l9!3{-IE8sc@CHB zOnpc{9izrZ?&8x1ZCqSKc1uWKb=Z-(x!PHLi_gsTrDpOUW%--z3jmXD zv>VFG(><5!iMaVb)`)1@KC+nGe*LBy38HL6b2q&=F7kb6p9_Q?6FkI*kJHtK>L%mb zkWKoT&9cV#dz5)Guf|wo7H`p=zMg&T=8ScA)n-TbxvCBGPU(!lAN%U&Wm+X3CS6ID zE6zDXK3v~YdO-?0NqCAv0Zf0ObQA#^Kbr-DLm{E96A3KK*9o>}bCaQJ%}=ovzpo)e z7s*FQ&%F6kyW)k%?I0F+bEHVwx3*K!7%Wv*z9?m+=jJ^+^5>@iuhDpd2abOkxG&<`Qz}QQJ^`HGD>NKl&GNQqR;T~QCxLpO|@yRTN^p>X~ZAi zn^mH@bHP403yko~*}Ac}^TR$rc(L$sN}MU5wZZtbtg^K=VQW)kazy$jTSc@I()78O z*(KX=<+&R*3vS2RCHegM=iLwH1g7|>a@vl&_gGU_S=PAhmSq{APmX#4jbiVFT%uHa5@ zDQ@i{Q`=K3<~7M}VU@St+f$0Sm~7+ot+G1=`?-T_Zd)8h5n!{0v*ct35*nkw_rJo! zT%(pPVlXsd*$K%&+mN!_J*xhq?C%v$C*3JVr8Ua4!K!*=Cu;Q{@CnsBA%U)`sjRGA!Ej*n??r7x8hu)S zW^8QqjY`t+0feV@>*O&5+XaD~<#P3EE#6)1ha)>@53KvQq#Zdhlgukr6?C#=e%XQPe&@S$+qB3J7lrg7Desb-Y4} z8?TE_T*j-gBF}A!1ScY4?kLbNK7Az2JI}RtJ2d68_k`uvw{Aks`Eh?k zPtG5^2okJe1FRNx;q`KOB3`)?pG1BiPV!-=o;T%j8{-<&b|r@yiE+aS%}_Z#zPFrC z)(Om_HEY&{hIV%LwgqC8!1*FX$-zLuJDtPFx8H7=c)O>k=kSdiq55O*dW3}q85xlS zi9+VmgvE=Ahud64X=1-BVx<@U$BY$)v#Act=kBVx z4%A?^5V7X0U{#pQW6E<&+7=#MzPr>*rXgfgqZ2>{0~>22=^O?YEn2kvK~+)`PDEnQ zWPLLd0gt~<;<*}6?&NK}YW_7(U0hm|DUOadwb*P$)@bF>XJbPdu~6A&NsO}DY|@g{ zTt$yuV@hd?YSLVDPiVC$;yZ0=*~YkqQOX28k4L{MtuvfuYLP`)w!2h!+MG(_PvD(niO zuBoxWDXJ14M`k<;29XCJsi2q%0V8lgeWJq~M8GBV2803vQ5iWkz5&B>-@MW)Ld~q_ zgKpHSpVxQ`qU&2lwoo>$tYy_cOZG~yN|C0kZ7~)rAg5GS)6~^hzWLI|WKm4(I9HV< zHZAL`kDmPJ!kle$jeCn4W5TtzRFlFKRZSZ=&28yUOkNgwb27kBILr>Z2JPQj0IN=$ zW!8a@Z*4htYwtmr-A}ba@-Jq%o@Vea;eX!*&-Rezdbfp@LN_qon3eC|9SkIk0|XOJ=if zOS)B2L$;h)TWSkE%=FQH)d(&pZn)eX)8qCzQgFYB6RQU`+ySW$&b+*4<*4?D?`~#1 z3qA4jc!p3|i6I-4+8=NWUR!9J(P{==l=AR~!i?V>5-kxsEb*-sp_Plc2KxN15|oQmY}K-&MULg~=|gX{u?g;K$WYuRNr2UA!3Ec+L!a|F&&< z@P-mTGpaIleQFP#No`Q-vP~%)gQZ1#P5hW3{CuNg934ZOc6Azgx@&9U+H@0ibwW%T zv0?AYidbsf;oxmX*Gyea)b6E6=jNLI6N4ojb$Du+Z|#oYK%%xqM99UBv9%2|ykp;F zJYPE@-Wt(!U<6qJunJEIML+z(P_qRJo}pLVUzY#Y-TbDerp~+HT)1~zyY|?ZhWUf0 zvJWuV;*t4s1ZOH>=2Mi1zkh$$gQ>;D#9^f`UWn;54{*4sg;$PgIeDuQgPJ^+$WrHGxUXfE)R1@8m7p2dxvD$2}YL;1g+z6qIMcFmw z%I20bxn|D{EYT2*089X*}ssk>vlV;|AMBTdIfZifuRT`qz>kIBZN25`dG1O>@uH5}?J z1D)T##?L>Jp&lPbRuO;rKqZJ++8+~3N{jXg+?MLzVFq2L2SkBB>)dMi@wy{6JGxpt zvv_ST8JeBDv8-iE#hMoe(qjnak;N^IUGLf)g_8$f@|!!QSW&60xR#)~77=Lvu-|WU zOm2g&!9;8bpY+c|uiTzlvNWfQs|hdvQ(M)ZUE~QtYU3NO=>C| zUCJ~|b994~4$5xsxA?i!7FX>*WAVnV;*PBR(rt+*Ne;qlq24wvezNT9kkfMUz-#pV zuC0B$->{9ZNvNKy?~;1UeUbOrdx#_**WEy}jvR-^E^=H)&48uDDGwez zNFaV0uKP6u?T(^-CJH>u;wLJS+-;dHk#G-%W&Mi6$pnS!6(S0u=}aF|0VDPx!*Uac zg%ZYthxJT=5z7UHRe=_7bY~$>~_NG2@tsyr#dg|Qy`~I7(ob>pz%{DitI(Z4xJHE@gY~9+tqCM;P z&XXv~;?BC%)zvU%GgwIK@*S08=BqQSyWqX) zKDh83o&-F+o0@vJu<)K?9h+#M{9)J7n3|LX1qA`$K(kvnypW8yyB(PRPBp4Be)sBvfpYqAMr$7CqQ1F+66+Aud9SWrAXKg?1$sW&# zZHTR5HP_N^BstBSayMsgPBF>K>SL`tYge5sUv;s3!gM?J>ElA{^c88QE~9!$reU4I zY>4e4nsZN&yqwmOwj?bqmR_qh&pS2w=+4jGmJ4~Su9TFyOO~zgowD1#)+DYNzfrb+ zS5`|$O_4QOl2DpeRb`gT3kp($ZI>u;_TGxKGdlaUU!=n0y2VrV%eD>py>#SI#9zYh z5OH>LXb2RkGs5tg_z%U@uYVG&A!`3%YAO~v=w1?RG|kF8c6aK9M zdqsz`9VNSycWd|PT4zMnj9)?<@>d=m*c5pzHI-4UN@qk(^}$4WIc9!%{PeguHg99I zx?H*PU|3aD7>ojzxA_1M3@xB2%8*`lxZlY9a)+tF=kn3|^tPk)??CK!va`tO< z_Z#T6M|7-SuSZktOjak0SPz5hr~bo$Z9|J56Lr4|xIj1X_+2zma_aRgi{2`e$>a|o zPAttoVk#N`T2R7Gp%>Aem=-S~*zaJPds`3*|BTxi+BVEN+gIFFywJODW?D zYw^qRWy~gV7tA3(+Qs%pyPUMmb*Q(&g;n6*Lp<<1z{UG^$+(;{WI9HWSYC~dKs1G> zkQO#v^k^tDA}s8MqqnwBy(J#MUHIP&h2r5QiDk*d>$y{2y%sx%x@%hB9N-h1o&dtaJXD{aY*NiNz2((GnMlwM>_j7`aGD(9oNl;_sqoEHD>BX@vzr&(!@jne08 z(`sS8Sy|mx?RX{gr88O`m9#8;)$XqT1&@At^NUYDSSw5E&f70Mtf^UASWqx-_uEUv z#O1^%pQ%roa6dt{d6}E?j*UJ4?N!^WOBT;fkrZ*_*M`N4;GrM7>kl6Eaay6cIwX%P ziJGg#>Yg8XWH>_>4wP6dnGluISPKS#S(96lb*C-%y^|{qI{lIxzl^;v)lRZ3Sj~|v z=2gYxkGqc_0xK3`pnp37y}f4h0VM=k!_4ogtrzQk!%E&@_7(| zUE{`X>}@zmKp=ni_`m-(zkGMypMySTkA&2gUY@@Fqrw(-#rXLZy7f!4B+1Q1&HdX> zIwE^qz2<_tx&k~>Q(u`reYyy@K3uigAt)$&Lt$;hx^}Bx8>=rokjzlYY6q_H_X$Du zA^G(S9PJ^R*bglv3tyL?;sb7N`_q+&ZGl!|(kOu{h8aQXx2}gPr}3y(Iu2O9tJj$z zb|QJWy!PSIA9kuLy@rzE&Q2HUkw%`i*hTr2)^FS7smN4uPF{|W+X^A#2)l+~*6wxOcMM{c$2`Ez8}Lyk(Ef zD6#x5@Xx4}{=Zl8V-WQ5cHBUN4spq|mYK{IS3>U+a*4R2u_n0bndt!T6I6IsLRRsmED!ky4nmRO;4`kNTLdD*L<0B4To1!Q4A=*5OyF&ykgXJfmg)#|2=?tT^|F% zaX@nQUntKdoZm@vr1gc!6Y;Z8Jh&o%dR90)DKDv(n7(IE7Qn5m#lU%MMF3X|a8y`> zCmJmvDR%&Rt8m-VQe3+b_h;hGvnn+3UelT}|K~fu+%6$7#o$5qm1xG81jH@O-2T6{>rzk@n)SM%!|9H8YYQEJ0)|Z23HlJ-^n7gQd4hS zzkVH8R|_%MPy?qO|1UpInZw!Ih!5K%XSK(VlF3};)ixftaV)Je_jbRQ^y9Ta(ZX+Z zS+Rb8elpyR#yd$VhEN(F`9BkWGk)i`c)jVX=2S?5nboP+bJqwJC)Y3Sb;A3_pqQaZ z3B&ntxSpSXE&ZCVsce_DwLS#}NwFNR1?HkbAymu%UIZc&;{Q!7GxS`q?~LtpB8LQF z)RIix&2b=);5_lY(2H(ftJ8Om3Ey0}e`Ok?Xm-RQHAdd+tm+lUE{EPU|BhZ+L^q5d ze#7ND3=gl97C*;Q0#w++7_eh~xOVZUJp5rv)L`-4eLz(hrI}jkbszAEvA4T;3ctL* zatqEX0f{sQeRgT$HJ9avTanTny+J%T=huwq-fztrk29W5yN_r)e)NkrdTq_4tEs_- zOAWd#POE+Aoudgi^w)0`zCg}d%mJ?#MK`t5Bk%PLz20J-Tbb6P|``-9t zpG$^@EF(89Ux(e!KW!nP8e{5Pl+&O$h?$9hWjM(8RsQ^8Nc2$p$U=lR8oAMEDh0CX z6-$75)D}_Ya#6H{y-(`(IrZHKT`vB6ig?s!NOCn4CfO*xzPNYvw;P5G;df!Old3LX zhBo3i1u_vsLnxQTgIIfKXrkWWy1J>b;DL)BdCR=nDF{yrggX5D^ik0nb=`8Y^VZ%E zEWFFrV^~_4^^4GcEC1Maa>NjWHhXQ_{m!fTq?HgxTWEOxP-`e$s!-IVj*gu<6B^od z@X3=WkLS!;6zr@g(9*R@Yfq^#yJ5wFhp&XT_u*!*HxgyCMDz;8jMPg*k$5bfaIs{{ z6$#cd5a*J}bk|nCJ|skhWgUcZLqm0E1wp6N^#Jqdqu%}n-h#gjwN)j#T&*UJS`GT6 zE*Dn52^sEr8-i#F5Sg(L1$}7eg=el_m!4av(iEcI{Q1t$9&j1795#Ha)fs~HeR7zA zqUr&CA6JKF z&Fd4Tk&BjBx%h{FF5mYvy67I^%b@4L!UQgmw$)%ZUX8OX?o{`5KZ}6~Xs&);G`vO;TkDncps7Owe@kH@rQfa| zO)vLO!X_Yhl1~rLSntcFvhYYvJ$(4yy}%Ch=S+aFwNIruzPXL?3siB$)a!YcFK?Di n`#IuxGDS%w56jI}RaM;{+5U5hiV1_V{kWdyTg~(E*o^r(3(}Tv>Ni3JZDxZI|5S3@A{#C z=BCmlBh6|ffNN2RDx?!F6-)n(m@5AI`oartGj2CfRj8Db?xL5-LzZy9UPfWvMVoi< zQn<=Yc+w`H&svvtAFleZSUjeVA3VaIeH*C<_t(}N>f%KQ;4}DBd@=p?^@N}rEpa7ylHMe5 zFWBNs(GQ6+#F?yN9};LnMy$TSnES07nWxYL>YoI_GGOif_4R~E>*d6oOgh%qb!%;E zkD_RY@tUxw)v5>U_kajq-(SUhu90D3txgn3!&mp$)&g0^-1ic6FRbd&J|rnZFmWf8 zhb@?O$aShyj-qZbX}Kg9HoQsjy!m@g8h_RjKfb=O68bjiHt2NisWa2Io2Iju5{XPQ zK0XM;-875{Q;A1ns9T;fzm13Hx<S^BJ?!!md718UDebAZN~+;* z!-~E3)|^V6%Vz3}@oy5g@&@d@1O~T%eSKm%-xdEWZoVhCQ^c#{R%+8xC2qt97!{Z2 zjS+*1U7kl0zKBI0@hp9p_t(}g?Svr5{5eC163b%yiD$5yY`pYV>>sHpz%#8`@^P~8 zM|DZvXwhA|rSE@zePX0$(&*EE@hv!(v_(tKQ4=2T;|Fx4X}b7vWDDC}d{2pObI7oY z`GgHWzP_;5kXQ8yq-Tk5Yi|+}P>BS5m6vZ0{n^J7;@5epMe7^*pOUv!_f&Ug8}9!4 z`o<|aa?rUpqhZrN%wTY7SLRZIXeTrZx+H?qJ-_t(}EPX)xh z_vF;Lm!iTV@j*Hj;Rr`a7mB{M6%0g~oXZBWyHeBmI3-`-Us$W`=~d!iaWC{8TYEjd zeIDlNj=&iFP+aaN25oh#?D%?CkEqG+AKzbD2i>zDRzg*Pp3VFLAxet)Vv?l(AKzbD z21!w}k%VkCfy$0QzP_qh;Q$&tW_Lh_|Bxo0E^q`-6Nrj zz(xWNF^&E8^@xLzmnZ-x>b~P@2J2Y)3tGsMv4Cttm4AJGWT`R6_;OVtLlWC#0ZHfL zgdCM%s|{b@Us&qEHUSp`MI=N)%EDA^ReybbVyPHXfV0A4pkzw15)6aGBMPuxe|>#p z9Z6{}CJP5{BAW$S2*IR!q=P1RYzo8s>+2Z7F0gK`4=h7F$|C?F#sUnHhR+8guqj*n z>+2Z-A)+3B@k&B|#1?eMU zTL=wATLx{e2<+?$KIsi%xPhDI%8+mGudHKZHg=vpLmxz1&o#VEBVT_Cd4NxKeTucN zn7EjX)r#pAlQaRB>Ex)peq3@89t$As~un8Us;DU=}JKmFq4UkVwh_gE(vC+ z-``(ZN3D0I%rsQY@iCJC4X~~BmYvob`eYGnF)G1@lKFvX@E_k_SqZIop6$cmu7f|< z!PowT<*x3ze_tTmhV$wCi(BuBo$9P>+*0O+0yxQlDo8F;Z{j8Q$;p-Wv`oeRrs*d`-?K=(ZZ)6N$Z^ zs$k(oSEjBe{=Ye<#9+y4_G+Qq1&r3O>)YrF%f{}F!kTqE9kS0jy4=N0@KA$u9Ohh9nw!kjBv~* z0ETE$;&Smhv>oDe@j5@)9A(1OHHXA?BruVN;J5Y)`uMqP{QO|675ja3_dHIWIRc`i zLWvmjw}NmKuK1mLdv+C01V%++g3ye}Be#o;n5siuKd@KPu@dUj$ig_mm}l2SDj+^5 zZxg#?!AB0#&chAa#P9gW6T?111~4L{1*tai_?}sRg=Bp*AjcEUjF0RU_4;sVA+MI0 z0Y(hNQ_rq~q5NZE!-?s!m#=$v2B2->6k5(FuHHy~lGehv6W;|jYJ~au0)dtQptfSE zNnB5sO0ZlO{=r{g$NO1|1u>~`(p38CeCkZQ2CMyl>C8p|>FHI}&t?AJhU-NmbRyz? zi+XOJC*X%mlZpHPJWuW?{}ceAZYU2F3*)SWU^S~2E&YPNi#rB_N7lh$!(r|^SC6im z*h2PkK=5>F`SE1Y_@JCnQ)wB`Ov!kX^(?*ge-s*)OW7?lz9>DIWE=YhW%VoZC@RO8 z&@%epaAy=Dem3>Ep+TT$+)%zKXB0Pz9dcc+HudR|`55moSt~`4L~g31ibuE2o$DP~ZVP{4tU@tv!-l~? ztW_E@m!7baNmd*;BKPsdfyE`)Tvo}&CgPLehZLYS_@z2oc%@1wIHi12&M9i0ZA{!# z1w8A;lGpBaf3L8(=H{P;=7~+e?s(%GhCM}C9>z8Szt`1pXoOhtRrmfHfkaZ$J>J2G zS2cIcOQUi{@mT8Y>vc!ZtiMfH=3=M?^jm>{PsZ`T93s5ZFbp>6vnD6yPWMLrx3%9-llZBeRMkbiRQ@W56-Q8rzT8zVDw>v#sgQXoIy3pD;vcT!KEQrn>G{b7 zLC5;~_<)pm)WfZhm~GbcQ3r;@R<7f;o9`W|Y_S+m(y*Ac#Ww+Xsc&mI>!6EeZFk~Jr6rCAhqa8LI%t!BZ`x;2Fn$N9}|6gCEN;6-uUEt}p zk|Dskv$Q6_%rkzU;V0HmGQ(AMc=$IFm&+H|Qg0P^ioJz>zR(t080*UrwNB>2rjI&H>FQ(mG=6b^g z*9^iQQyx$eSi5YWr&}iA_Dr(u>@^U@5JHzt=2QMe;xN%%llwXdlgT;plMp9uc77B0PD5uy0!4JG@KKERV!(g~A|IzB2V^BuxmkzQ? zt(m&#)`_P3tQ|wj0!bv9PzfTihQzSju7px#Tufv7oGcRsW~J0NXVj^R(C{nqT+4II z|IN#sS5MIRuH098IZQs*PG@V5oHucq$$8(Rs2mT_OWulG=KV0f@yKWivIn8p&C%vTl- zE&M}K9pR-YP-PH5AisQw1g|P+kVK)Z&u)xJEIf-$m3$bD#?7<2G3%M$m zZVnrq#HygeM^D?b4cT>}-DVHw4W&$TaEL?Z|27TmS7mz; z8<`Wz5iHIuUT3_Y{wfN4e)Pq3(`D1ukb~IQCcBee#-c7PW#%LIydM@e%^9lyfSH9t z?3EnUsl|@A(@1&1h-5TtKKQ3UkX`T>cqTAc=U{Ypn67~UuQ}c`jSlS4dy60EVzV%L`C~@`w{tx zfqYpuda#_Q_L};e9V!T-SiYu$lr+&bV+k0+PV%?%S^dw3exE%OCwwCoADhQc@8;4) z+A5luMNrmM%*s4-2A^G$V|BFAZ9#fUdeG^d=49R1vR~THKK{rrc_Q_@b)nwt`HcUA zHu}G3s{tR{tLuv>q%wcEsh7f;+lH^$DmX?0NxJ~{6CdTO2+|qqdcVYpoumk`;LARx z20t6W*_q58*b=O|#g(nCq~KFc*f6=8JNthn_H2Y7#fWS^dTAfUN?t6kBMJIn>X(YY JXQ>zsYW`HbLJR-^ literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame07_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame07_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..c891b29686581c703df5cb3debd0939294f2d71c GIT binary patch literal 11842 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 Ie0uu-0dW5@umAu6 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame08_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame08_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..8dffa2890f46d66378b866440d5473b0dcee4f3d GIT binary patch literal 4132 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

eUHtj#(xKOwPP?-Itit3qU&^%;Tf z)@(7F?0iWBG9WJ{sK@KRbD-7Pd5hz9-7|3?JV@BT_`_`CHKh(2289ZV z4WZJr5daJ`1|hANz1=0-c*9d|_giPoE z>UvoWhAubnzaluoV+w!YHzllEkOi3yD#nsHcz5MKEIrKXwRhPr#^E*B%s412cUKf< MuYa)biplT#ABF*_qyPW_ literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame13_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame13_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..c5308cb492a0640311442f217838bada43025180 GIT binary patch literal 12550 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 z z5q0SmA6)x7mKQB$YCLMZhOmL}$CRVWAkZPOCT1>svS}aFXgS>i z!c&Ri6djt>nPiGCO{zJ=cl!2c;~tM~UK{Z|QiZ}za$IPfP1xD!9uijnirWT+5oVX# za1F*z&4Vx-^`+)0`riDZ+Bb#v^4W=T6oWNej_JF5wxKVVl!cz`sgG&OP5^qM&XjxQ zpE~vENV^&)F`07B^Sd1q=V1&qHrVQDkE-x!y!CVStIDOOPqcF>MijWimcjnuy8HcS z*{hoP>X&mu{#Yu0<9RK3etB}fX-{06DD&9e6i}lPlv}V1sZ>2i!$E>`%w)B{6?Q^q^9Wd`sj%Q@!MSrflZ$P8+ z^@t@q1P?xFYQ7P%jAB&_3aRuWyVERWB*CJk`q#8qFrdtjnn`F!v5ybAVA#vXw0FH$@DDKKcrtchxg} zWt#6pY@B}I%$FbN#>kWS_{@U@-N=t5wo_a}R2g^)BB&-lC$qsO5`Em1bi?YDg!g;F zw*bnHQ0f{6cJ+Ck4S_nye9|*pHoShzN+RJ7$2=8EMUH6*v3yJfdLGJ{uX)s2C z#E6t<9?WcD1*PQ326kW7=~$mf>8XZRFBXo#`SXatX3!q13x+tvuVd=AiZkKIY^R$> z_OxOrq-b2bb`qQvUz&Pz!EV-#;;WxL`{Q&+attL#T2`_t0W{6#r%cRs)6@L1a>>ho zX1=5p^@K)-FaYH*(%8{`bzwi5!TDAaII~wg?f5V^>2tgpyz#s(gxDPr9lJX3aYCau zvQYPvikE1Ic46VC+u0mN(*#P$n#+uG{seKw+;7dDws#!Gl@0A*P*wF^lv6JZdlD3z zCI^WZA@y}EAx1z zCQn+)CI~2cdk(UOENFib=K+_tsD_uqx!)dUdgPjw*9_+>%**Qt!kzN+qY2)pc{A|c ziIq3&oKfYJ2PH*QQMXK{YAx;bV2!h{l1Dge;s=&g&Su0$k0n95{8_G~EpL;RAXv0o z<|*mMadf0@+crcMOL(9Z*@1@kiGx7s=PKJT=$IrE^XQ&UNDEn&J@WaL-d7oGep8tp zwE^m<DLrP?}Uod5U@s6o=JIUuRR%p95j6y@k#nZeM^uq}fbE;-2GX?jeqp*_qG zf+`(Z&(>}?xxn=ftHdd~NUX>v3X z7vaNhZbS@1hpg18EsHaJ-QD>>7U#Z*%;X%ccaXW^O;G!+T`H(qt|yz7^zTpgZqS8V zMssATCiJC9oPGVJ`}I5TIHlPRuLtZ@Q6Q0ZtlhvavXT3{tj5uO9d#_`IP-0>bOd0> z5pq&vdiw0R@&I?!5)ya)RyYEvgOhYu=Z&QTqq_&Tv~uOwyJR*;O!rgO^e_Eo=)Fz~M>A4u4!KSL=_8lW zU{ZmC!Mt~=0cnjJ6@ZyE8QoFK4SD&oKzT&?p9$ord%k7764(P7_Ts%LI9I@I0J#}7d2micZ~wgrI*yBc>F4bIZ<)hNw+m!zk@dAKbVUrhXQtVT z$^aB)<62+jLp8+YXhyx_Bhte-L!5_1;-VPGE?^h%oR7}XJl~3Uf}Lz-uRWChX0dSqO!{OL9u+@D`3Ix95n>zrq#k{ng-!&)*!ZJZv1;T3pNARtZ zRACb}hyytuKoBOLkls`4foA}$U@HXr{Ru&sioJqy;2B<=RaruDr1HOH1dQ$mB~7vb zy3fE5i~w(x&xR>34P^7GQK*8ZQO+~svtI}=9I=s*H%3E7AkSV1t8)$vk%1sP@)m;|IZ%Ca2kfu1%ia9LP-Y53?G7^k@HX+3-GfmnxJ%b z$s&IMKfRdn0)T`-ad``lGvd@H0JnIY0-$|(j^?3@PddQXgiFr=%@cr4xOvSHMhGK# zU}V1dNF-(y6fpm9e##h;TzC=)d_#~d3J-t)7sz>0aZE|Fm=F;v9ZS0ic7Ow(BpW;w z89~K!4K?oQ4uj!Wzo3s@KrlcMVL$?dJTNq`hQ1*m2!*X?;2a<}L@_e6Q3qmdmNmdJ zY9B=ZTG>{xTyS43H_wCIY--$yid1ib;TUjw4xCM#89M401uw0n7i&K;(y8KaIwF z;dKcF%7esSRVp>;{HM2Blv1dofpN`7ff)yZ@KN*N5}_OzlI6V!UdDMWWoo@3APWK9 z%V@UHT%4xN?aSC$Ov7l75yhf}d2-qY-p&s8vij~O2J(EL1KwIGBhBtmZ_pY zwmmQDCA~l&0JJ~a5FGjkw2R456dX*S@-O)mG_rA)#<}4f&jKNvqBb_@8%PplY%ffy zQtK4dhBiqCBp`JovXwZ3hqmw`K|tvT49G!*24t4}JtCbqgl;J8;f;hB1Jr4?k3yu@ zr`PCO6^XM%EjzNh#{7N(37|E=e?UbCw{0c5SBHp7R10>)7bK`9^Gg~^&>DEIu9<^! zD5}dMY~Czt3oh%jZK+Qkb`VXP14dE?X8{S`xBQE%{%YyhYHcNejhh3mK?0JLA`wG~ zz@Mi%6;K{p-yPT|s^l2(5dp{Z7hib(sp7lVte&K$k!nR|T41wb9^nFV`+J7Ez(Vq5 zWQJrxBSpB_m7hGMX%LMGkijeEf8)2Ws25j})kW*qHhov}mmf?1;v(^q2O=exIbRpB ziy?a;#Vbw%%%@<`y?exAx9ePEW6$~#fog@cAVX=KihV&Qvxz)pM+siE$ns^BpfCQC(JW zjqkyY zwd%Xq2DQ7AqJjwC9ver3aDPUQlDllpQ3UB1t+OgCtQu)(WEtd2C|FDE0=m4e#L zJnFx@JDz4}k11a=t`Q#4ymipDD`RHqrqZm^n3)W(FmteSR>^?X)|w{CEq(8$3%S!l zikA;AOP%*&#(VF@=v^JY{~Qx){e)lpUfFrJIa_Tiiiwl>rqZwpuef6o)6PW5<@}gy zEGOkT#s_ASlSOd(eQ%cwajKxQGSlJsktLqkLQ+HdslSWZ^zfC<;`i8{0Wj~!Rd1Ji zLxjh|T|BVofjYPOsQp>TZ$0D6wlro zffWH&k$g(=oG4jMF7iE{INI!A!x?7h&DP`M%K6vL(#(8ARjn#O&l;H0T2X6jRIf}+ zBon8t$?=l*k?xL|SFU$ibh_u?N=V?;Y$)4~P)GpH?C4rqdfLM|2#Bp+u1$ldhVnqbHU7BCxw$=$8Yt zi78h%x4UGvfsacLUv@e_Ty*`q^W6+>SA=+f|6YFR2(SOrC$4|L;pyGmU19RASMQh! znOLTF!RT!Bt~wEGd+y}A{_c!4(jBcGisKl`Y2;w*Yft>f*X+o~P^<5jb-?$QR+*zb zZdxC^VRB1=WnZw7AQ}i3omJ>&X+s!@l<3FeWRS07*({$ghmU+bboi?~79Gd6|6{@G zjI8M{^3VWKc}2xnNsluE=33{XuTy_D1POwBMB8=wIz`ChXqX0Z5o1rEK4rZksOMp2 zP<|-IigEptfbs-50}^%s7qA4REJN?5fM>ITp-_fAuI%eB=T_72G2vRL(@*Kn>K67W zBfoLt{dm52w?xSraReV0=PoTSVxg#bu4C7&ML7S^`^%iKy1_;{9QJay+Q@O)sDn+} z{!@KJ0WTgHglDjzEH*=67(gI1@K3@eh+tF{XeYoQ2E_ZA0fOclu@%RnF9NO*eJCOIIJXRFkHiPw$g3X` zGls!zXk9h{EK(;(nmLZ5vIzpAONHI0dnjUnR>~%!o`4YH%^(F)RWDO1iZo<|M(8Eg z*p&s_woxQRM5M9Up0%*RA#p~IMd|qYe9zdB5~T&{1Ii0W!XT4%fqJ)yBiKBs^P-@g zyX3q!V7hoUIF#~%0~C<#l!TyO99x4!(TJ)n07fa8u^{ni9QX~`01UWUq)A11 z;8eiOi>IN_*4G!^Us-@&T#POgcpk@6duB&F1>U1}GXYazGT_ZaAmM{D;yHkVJbhXs zj*BzkPugD^0TP-d2?Yx{fu~~xLCHW)GE=GwF~(4U4P)2~J0m`VaQ^9Mmj zY_eKC4jk9uJU|*-3f-^-!beCcWrVS*9ZzoJ`%n^%;1BLnJX|0j>5%S&tJpV`<{4Bj zPQhUd5djylIced*wV6QA0xVAiw)tDcGS6fhkd#wMjIWJwb>lni!l~uh0k*`#y|-^U zwGM{?@Wa)lT~~hCL9#)`yDccxNzHJ=(~O2-pCq6g6fB0tEJuTxmass0ti#3d5}IRC zT7XYH#Ol-l9T`gSfdC8Hf|-KjyQHnF+mwd+C{H;UtQp(=wYzwU5cCkA(>-2`SN(^0vRbKzb9_*_Y`;II6!gT-ix^xjLokW(DfP$9TL?RRy z>WhF3oRf1Q*9@(v$r&=9Z<1a8b?n8!Aja6HY8z2E6f5+-y5uS^_oKDnMXX-N5xHY3 z%z2tt)Th36es{UHIotGg@T&KYiM+kMSn;ezZl$6hQ##(okr;v@(HsXFj-W|GMXLUG zD{34*I}&wS^OL)~BsW6&@oSOs3tqW3pO)mDRcb5si!a9Y%vk6&fyKn-fo__EIvsIX z-di4}+s9@_obMg5M)Z4BjY8s4=LF~L!%NBmEzYwXY(%KpqY(U7Id%L*b({48pJRuM zgW4y4mPa3bbujU&(>KNt;`hu2)Yw4>&-?lP`OoJ40Nv4(t+6}G7(-Rl`o_A9V$FuU z?XwB9nLt!Tx#MCzqG|{Pf<^6Tz`6UW55FiI|1>Yp5ai#s_6OJTgAU9Y9H~Ohs8V!n ztBWw?siC34CH%R5#R{<)q-S3m{`QRT(I9tPSaI<6_k}NpaBMYDIs_Rav^8ACVrZjtX#pw*)X8 z5?rUl!vqEs9AIe3p^b^~jw2&4G>=}_gCAydn|c1m&R2fx3eIypRZ9J}eI}(;i9xP& zrb)RB(CdSR@)L0eL#)HWKdTS0Mns+K-C7Wr-y~^EGkx+sXQ;NA6Ad`OVO=+0Er(QMya5V9jus?!xbis0y&C zttYhbSe#c`2|m4+{v9SmUh$%@HhpiVOkK?3*+O-Y7l-FE~+|6)5(9mF!96WP-TVtZ`2^xvHE^DV{Fo9$a9e4 z&U07$Onqc!Rgk42C4%>4mK&-YpolKa(n))2%6o1vo|H8DhiwC>!f$kgZPASMb#Um+ zh3V0rwKM>79sWU{N!BA;+QlN^+kltq{sI4X3E=hV zXy|Z(1Xty?;GSoZ zwD6~8jaY}v__`D%%|I%Pdg;~_ri%)xj(@J{g3@iBaZwSSq$QQw2g0P3+g<3Hy>P@q z{D3-ZwmBq}kk6ST%gzAWR{!ixN&kEO{PNFcF8_4q#KfO{L_Yj!F+BRy_lVn8rFLiI z$4LIo%gzf?FS@TfclJf0NUoQEiuar|$I);g!HRb-QtQsL5*g0p_}w80eMNpl>!QKO z6+TZNJ|JD7vEt-iFa}WN*!=dW`OiOMB?gh5r6~pti5`BNB{&Y3y%I~hMy)bBs>yPl z(`qk8M-ECFmiJuWF(DWXhK>@iX0j>@^B&wP2k&3C2&S$s4P^>Wn?5pWM6gOcE+x{7lJw zTtk=LfjP3DK%|0$(+B0Voc{^AN*>$U*njP91M(?$91)YHCG zpu=wYI1(wFwRfmu*9=oJC|4~tHt*zB-vL=1k z@fqAThXcM6!1J%hV`{u>Z0NNqbN7Zh_R$IR6DJ;_@ZMYlt=9!p$gF%ehH@73en#eCMu01bIU1@TMU%2jpq%#$f z-yHLHTlbfDXC@}rmYMx6#WkBIOC#bEi)%w`%VR`&={@OPan(#q>9S)lY_vFUIA_)t zmmLgEP;{kBCop{yydV3Cd=PT&qr&*mYs(H68{#h3>JP8N+4NE^?ybH!ZD@cH1)HMN0;nO;j9(@r9hPSoTT zMjcNe{-h5NE2P#?X$NB8Bot~W^2%f^lqlz0&16JEMqo}2uz2`+J zirUM|ZSUr2G~T;4FW<^q7whs_6km#UbO4I=;0ws;-rSu^bxlC`wDs{dx}67F?@tIb zl6d^3we$vu6M2wUw5YJ(LQ9T6;{q{=9fC+jN*A9u3O7p={c!?}hNv4pR)*(>u3(2fx2)RN98 zUzF9EZ%#b5+unTh&8;iv?o(6-*ZzQbA+v!iR?6?->$1WWYrbEztuuSZhv%#_zUiL- zE~Q#)&cU3DvY@jjC?0@lxDv z_1TV~4^Dr2u#DABhS@LGh_12PH5Vgac)V}EWpZY8Q+HyIZgxpc;q093#XXv`YH6o= zLvwzQE~F+hp-Qywiw*!UWA-d%5HH%8wJthHtrBHc^luE#wA5{$SuwCzdF~^oC-;W( zs?!uM!pt7+!ckxdswYrn22444pGo#|qjCFL?d|S$(`NJDCydWsYq+BGb(%UKZFFMm*->|89f3ujX#6Tfj7j$#%?`SxlG`11qSP4VKHj{&XQh!GrxXy)OviSD46}@z3tI-cX@i?~xU$aq{GRFurYqV@E#>PK2_fAjOVlgs?w_ytOVOWZs6BbY}4-@_)s!=Fm~n+ z@zfVyt0yeDzSet;{OnVh1YxT_m~TU8=cETsQA@2-wKnP-D{w!r`5&it*PzdZe!sDR zp)qgVmOw#ZDuK&L6%j|W5#XTS5bB2}r#!eN;2tC5l8g!bBT-^uQr%wLJ{yPI1o74P zhZ&;klrSFod0ceHq$wwYs!Du><=2j$xF$Y-tLsB=7B9GhuRs3R(ZnS4z~b@Ya|L;t+IZ0i{?%L$v0PrK?KRxGg2El-Tw zxsR|ALk~mh$Gn~5*9XKWFZv1N4@833kr&3t{2*^ZHoeT-+dB_|_yE;S)#cR1hlPfP z0@jFFu#u;GSJh3$H+hTkKd`32@Pyv^QI4tJqcBXB+&2I1C+t9!(rPkX^ z%9-lnFVV)DG$py~r*R&q{-_uo6dV=C$Ce%ieSf3BYHyCBt0po(u2(@RyiYXwy)$w{ z|MIz8*&C_QqWBzT&a|SP>!)vEnv(rz5{V5NF~8qEdE)l2+yrIrF=bVReD>lDWA>6V zVr2vuK^kh3N_urnL|yPENp6LvE5V%B!fcT4Ehs2hVm7z8H*GUUB-Wz8u}x+OxrGsa zS(f_f-7)VY;N6j!*iJL@q6~>7EKIXNw&2Eo}83&O8D z;d23s<#H@BsCk5FBSU!PVJ>^_O6nuQhk)7vrlK}59lhR7Q5ZfH{MU&o-W>3*Woi* zaY@mSVXtLrLWMoOb@99?>ENNP(ARU+_Qak!-D$#btS$G0^pNzH!q%voIa{c%MkdEA zPno&0xh|(lAxo~%aXC34Z;y%%ag{i9NX9@xlwRuD;1_=f?J4}LL27+%b-QK<#W(*n=;C`~sX6(5n;*47KBPPGjF zbuwbF>Vv+0z5KGm+5K7l`RXP9m62pxM@f`<{_jKIyuavm=A#|vgbnLU%hGcVq9(bb zFR;MpHlxg%RGhv}R``BVYtC}*cAIkYlI_hkz%AqA%lcIvacz-zogK#XO#vGtH(JU! z&-x%CMBh1iPLW>nI*$gwKtnEA5D>Cw&-a8>x|N~zA^eR zM<8t&H{@t?G@W>11o2>%BDhi!h#4hrC)>eJU)w6Yy=?sx0i_XwKmx=6PE6!+#1--o(WFU(RBeD3<0_a9)ayQJaf5S7&|{d72O2 z@uu0JtYPX>68#m*oLotH?P6~p+WZ}MiRPNKF#O}_+Sb-`OVon+rhUZm!VO*75gFy` zw$`xYiJLS5(NJ3!y#Cu`OHyQa(i+377gkD3;=~8u?}=|snl7_VW5|Uv*|Y_+I9Xz~ z?~5~Mo*ND&9=z00(@{fYF;UYZ6BMF^K#stWw6&+F_4~Vfzh@ZXLCn-t+{r<`vRJMp z?IaQKZx7G&mq`h@FL=BVzNI{_B#41fo&Z3f>*KN>JF4C(rg%T_`1Rlrix+de= zGB|>b(r>Ri8-mM~6(0}`SkIb}m<nLfRW=LrxKFHo?WIo4> zW5pvx0KRZBuE5UD+qa``_xJbjy^iSxnOL^J4XZmBpZ{sfibsj@uBtZ#ufPr68I2{? zSPFu6I=L|4S$48DKv~!?u7L>`Kq>;QCtHXx1(P^y&6-o=9L+TW28Q42`YZ}`bQPJrSV<211pm+R ziZ4jaCbwa}D5|Vf*(JeYmD3I$IYK4V$zI7(@xUuV#5ZC^O4u{YzZSDA;LQB)iJ+jM zD4ro$e86#dXa&Y8>epxR5K!%k0ic?(2gaoyX^^MzvRG_3f_WPbIymFSix)={tA%kF zpH+w{v!_zRha>tp1c%BXc>ez1$KU{n5-{h1F`v!D$8|sa^j){IC4gVrTI*%YPPnC6 zlM*J@oUAd!ixa|lZ4ILA{o&!{oXB5a6B>A)!tC50L_lMh%1DW9Aygz(8=G{TIiT#t zO9_c)h+o-mUWL3uewFxc=6#%xPJJ#XzQSF# z%6Qr5LiU5=28sjpzZRPcn=#%#z+gJ6LKhkK&lTf0Za%}|IFeS;nCMWn&gLG*ibzltsuLwx$N?24w*eQn zIfA9qkUb!9C&MFzi6_FUlgHhG*Oc10G`I7eVi6{v1nhHIgoo%!9;enna;Hv$HFKSe z1Z0kW4oFf8cq$<4( z)7g!O!h(l}-i0@cdz`-DZ_cbPEiIL#!ty8x4abE#33C7ec3*PhmfzPmyG>i1Fc9nf z$q8fhk84hPBsp4&5KSEZuRN3YaXi5F)mW;hMi~Eicvym0V&bV&L#Gt6vF9^RljS2E zh+}2jK;pwg>&u&MjGQ5dhqvA_n_J2gs{s!jASx0WXA8>9S1$kZ_VO>k=W%t|SiIw2 zDp1m=AO0lvg#1Qa<~j+r@MPW=z=o-Bv}^1=OZjLElL z_8>K=$=b}&ygWQPpjjxpz$JK!h#X8F^LyZvQ&d!d;tIopN$sK2PY-|fR7ps2FCXoM zTdpQ#fi^;fwZ+$uPW}C8hrI~8tdJ9P z{y$%So~0x&)6Cy7-R|*FW)_JVKt^gBlX~2tUKWJ>A1U z7k((8i(7!U-KbjK%U*~I?;+i=95Z&nnpVi~D+IrB5T$gDjg3uI&tl{S1u|LBiWT+y zZd97CIEBZ;g(J@}oQSHeo%X^Duc0(&-MV%A_M}O^zQCKdA5xCu{$2!Z<$dH9+dy`oN>HZ457I89P~ItVD64dBwxeGic5&ojHay2uLw zaQ}X(*?&{v60fBDPlF+&HlHpmP4w@swJJAShBA_)wCk*$3yT+GL~0G+1z1Q@wzW5$ z+=`+EwHP1nPSVy&Av^`!cPWo)w=1=(h};knr!PrNR3z2VwRu=JeCyazENq&*598Sx zR)hXfY}*V3T0HJis_~0+Lcpbc#@sO>cq&?DvY*bPkV4yRcE{s-W=qD)R^_)RUUr3X zrts|C#vIehqf(1f$yq~HF|g|W+3Gu;yirAIS&Mf zdRTA>UkMI70{4}5&bhaRA=6_DXE=tsk;0up!I7h(uijarZB!~#tiioYa&ii|O57WO zhbuqYi@er9NyP)>-Mc%(>@AIPw9N;%K7(wv_%@FxgX;ovpFNH zv@fgX;E`{39_)G)w)JB9R;8Ns-pkwpB^zs@vbUkT3J>@8&YG3wl@*ew^6Va?DPaYV zT2ISlr0{EyL0poyxbHwY$|^QaM&1|nc~~*c#EmlyBg63aZ6}3-tpWf1^yZ9=TT1Pk z9ZL5CtMYE&MB1@$lTFsk+3pj**kpnn0p8KSkk$fZTLX34ZpwjdhYv1xA3OXC+@ys) zLX7aq@3hE9JsbI_#MT9Cp%RkFzZ;2lDqx zyBpWKFiC_>{68vX@hi$rC!^YHQ|Wr9s2ye&Ks8=<3f0oYJc4#&iN z9lWH8`Ca%7rec92&Vffw8f~^+&P{ITvqz5z(_SayT$~YH=5UubO0+n%yT><-T%m1{p_m~9y{i&anhGq(m@EgWgv|0 z1Cf9u2!aCz>@U+UAo_&NL6sJb*!`(BFDXNU=? zym#*&9y`);w zAcY6I%%Co@zO!G~>u>}B|56-lEWaiw{xg?fcZ3O{zHc1=_%}R!bp60;ARUh0hM;Sw z4*wjz!Qvg8sMnX4LWs(=3wKJ1V1ry#M@DES!4W`hY!e=g33XcrnYNCaRIBnLaH!cw|WM>gwuq@WT%uJ{)`H6+b_NK_QbbA$TOT z3sap~aOo_S{eiWEYG$}bsz#pohv JK!yby_+M8zY>NN@ literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame15_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame15_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..8d024f352a19bbbf952d0b70a3d48dfcfc92d742 GIT binary patch literal 4225 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 XJOM^J{_Z0Q@|!#wJQDT{rW%p_PT*pD literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame15_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame15_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..f5ba4363e9633eab77487f2d2e41003fadff6ff1 GIT binary patch literal 12296 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 W{eJv<@*9WdSGyU&*MU_%P5f`z-FCSE literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame16_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame16_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..d28b5cf1f7690bd1458459c48b9ce123c2f390a0 GIT binary patch literal 4303 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame16_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame16_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..64f7c91ece31edc70582e04bacebafc951310fd3 GIT binary patch literal 12592 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≧JNDJzFhzB#%1zSTYG95dGD-Q(iyN@ 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 NLU<)qLY)+v{{datnjZiF literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame18_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame18_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..5a2345634f322446675d0c66abced1b127e98249 GIT binary patch literal 12386 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

Ji6Iwu`Leb++aU4M+94rgb^BLec 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 R%m>srX23T^n{h5>U%&@mlfVD~ literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame20_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame20_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..61cc5f5a93f059aa7dc74a0801121d956fabf1cf GIT binary patch literal 12626 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 OFjF>cCgtg3O`QHcBVzmj literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame21_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame21_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..4b928d1fff34deb993e84c4c1c2a71a81325cca7 GIT binary patch literal 12512 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame22_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame22_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..9e199754cef48eed2fd76f377636f875f3e968ad GIT binary patch literal 4231 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame22_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame22_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..33baa6b66157530b1b83cc6f86d0488398fd6682 GIT binary patch literal 12330 zcmYki30xEB_BVcJGD#*O%7je>mmvfM6dOTtX_X2*MDwWXEzs#WQNt#-R=>Ef+gblx+)|Nr}W!zUq`JUP!-E~6iOrtPy~kA`BLKks1Q>wXaGB;EJ|{#`ED^<{ARZ;a`!RkE z?*N6ba^L|NUf@6!pFbvjUO3Ov<|w!CF}pP^^iYgd7TcWKf2}gr(FrK$}bsgW*3wr2;48=HMp5 z0>Ois0RuQ>pr9$}`G0l>E6_+=0kohUmPUaL2pXs;K(Z{<63H^CCYaP}_%Xrcr=x;! zX^Oml1HQsJ8^_5B#oIs=@TNw{(p)5YH=iFU(+K0X8Z>Xw>lTIpy?M_$)>C-m8NM0uVUyZ8IT3lEbZuuCBc=%6X45$ zg9E|sZa=Ky)cToD+}Vd^kX@QWvY-+8t+yH>@*kwYLq~yvCs@&unGZeZrx3V zm5>EfDpCO&0?;?c%LV~~Z|N~Cz$FC-igh%wL_g5Hs2N?H#wq)C9te={%&>4ofUKoa z#S2jeRKC%$d=Dd#G$fgoMYy@-VOGM@q9%3t3QKB_A(zbK!`-POQv%XRdm-9)$LM;Hho9g;wreIq$&1Q)<~AXNv^ zfTTKw=LyPahfu&U^wp|blm(LS^8e*16gfhN_V*kE4)sf>*cI>S z<{!viTfdmT-=p$pB^rWZb{FnTK(MA8mAQZA(Bw4}t7odd)R){ocp|+0}aTy>jlQFFR%`mD-nv##GMOGXso)arMRxaXhHV2%zkM z;ZLL5OFISO`Ey82TR$9H>!UZt-fo)O>;2`M_K1a+nl={!gnXxufsh$o0C610 zGE83;5E7S`>k%r0S&I%{KBatb-Vk$SW?(H9!h(!H5tZd1RoZW83>t1uCxLm5fQya{ zhMOaVUPL@t66y`^XbcGn2`m++zj9#$hYb0H-LCIqk59;vTbI6i+`C=h9@0cRD=n3e zhetm7{kbpLnlg)afEpPhVE}-;IsiLms5TBwMVV=y0UkIX3>J-ofdDIT97akwT=cB< zMwca3L9Iqo2Xp36W{PYF-et*GWdSIqj~pPo$D&eR5L{SZBoC5$91s2_XC z5GawA-D2mFcRzT!I9=P>?%qh2-_70f{jS#5#S35f(;4;E;InrTr}QLJb@;K!ei-fm z7*_rLfAnJ%J>|%m_Ml@|-v7$myK7tVwunun?R230T1s-8+pX`?jp>Sk6qkg+G;AaV zdo{Du&`@@{_C3pvx@hgFxOf_fGSV_L-b9)XPdM(`tWQffMHV>OCR^!gTl4YgrinD0 z8lK=Uv(5tE)dqx$NB^bZkA7hNTmz5mkAZ{!TyS!=#$0Z7in;ej#48>u^6cj36R%Yv zui`JSbWZp(x%ny)wT?1Egn8^>UXQ$;9^)KidguLP*VyYB zFK_&rkbNK~#xwI*0nggS>zUahCE6Q>n+Z@-fR;0Wt`@2g6Us}c9^dw##PwG{&4a_H%o27hB@UvQmX+EkdNU~_{%s44G z{~+RP#82MRS;v`kP%f?^#E;+p!xRCARi(+9#_{4Mv?3NcE zETRw=0r*mn0Yv*Zghr^5rKmENF6%f`?VH-pSGYnpCZ~r0GjA<4?V{^#cW12djUiA^ z{pSe>ROgiX_7pt4H*JtWmKttw=u!Z5na~4<$L^in)7@L2C@ML9L(bQ=xr ztvej~d+OWkCkKuGsxw(u1|;r}PtcUHIsiwlL(y$DI!kt@pe7$ELB^z($vFa%TTK*~ zZ1)UY{ddfiX}+=F{1y|vc}eW?k*{k3)Tm4b$_~RAjfT_&lTosMXfwwu%X--e588R` zsNM5IUAW02;7tLkpTGSie{@jHuJApqQmLe+QcCb~bu!hvW>LL8o*F4atez`Ki! zHw;Qi-MR7SyM1?Fdo6iB!V0&w$7d4cVzER`>X#|`Lk7x6aaRWu=iIwyYAeou6euU3 z5(KFRmjE$CZ}JrMeqdWV^m_P&%)?z*;1Lx4L z#fl0@A}}iSL;R9>S9q4ulEKIkglf@I5cn7Zj2!-mV*kb+_}g-Zp}eBt})wjH{2d59TMh| zJ0wMF=vqwO0MSxW>B39kBC#GF45|#2U~nI&phF*zj-Eu+p!$K;V9m%P>tLlM4aksh z16;&!nA!NEM6yJTByAxE*e-xX2QY5LH{3J;6BbvCDwAN2a^UJ*0wY5D4%lJpHj<5ei#Of6*CWkUrGzuJ@{@swTH&NCC#DY zfHVThVnIg&sYJGbCZ*N*bz^4)i>xCtR3(9uOT3^?usT7j;|q`b;w3q~qZ5RF1}%WH zNghOU5I_a+R*J`EBwl5)D8|buHt>Iu1|yhBBm`u&L;+}n4i_OQ1r1&J3nN|w83y|m z+20;w4Zu}+jZ;NQ`!g(R6dML)lSLmOus#NTxWo{;34>P8MTuSil^;k#@i(J9;#e@P zAb}E)B?A`OSo|rFjV|Cck}4R9PeQGwkdG=rnZ34~;3zaSl8@WOR>-!*2gN0WHMr`; zAmr|qe_z0Msra}J-;eT`Tr5xcSCROE>%&?el`l~oW<_NX9AyBA+~G%)9Bko{=^Hwr zcyM;F?CttC&Z$>9F=4x5B@YiE@EI6*J42ErPXut8=v{J24X(*ua zsgd&rjw3Q=ubkiNheb7$o1IZ{t$O`hiK%hxVH z@X_ITT&_dy(}2Lrvy#!#UpS8AtsK<6WCr(37T(PT?tf0WeikqND*9q%^RR>)7ox2S zrW7t>?}2$kmL?^kHi(OWf=}MeRRu6rTHtuRgbYc9Q-hPtB&p+SQfNQSd+?sg4|W{1 znMhuL($$?Bi3yfub95oFOS;hr*f2>9o*shiS_y7z0Eg& z7JUPI@?o%RQ1M0{D1l++#~%eb!)FKsH*B{(1ZkDF+5&za)uOss$?^;cJkOEXln@k+ z`Z4oRgG?wmpxNu=2o|Y;FQ5gMfSlkZEBYfACLC(MiBZEpzK($7g*c-IS7V9I6DA}kKR|SxApedOIz1g>#@@%rbAd*6^}n@ z$KV&n7eT#Kf6Ln*Gay=AZn#v;bIB<_$M?DmTz(%*KlA;+e-?m^sJQ*Wr%>{iUh2Lu zr~C2cyLaDzMi6eXA6hef4L?`One%mt7w3K}L2(XZ3kN{qufnCU$N!uQ0j}N_8fE&8 zgdx*`N&Vk*rsi9=g=2)8QBD>U-wykhWhK40@80$vv4vV%-R4#vuzLXnPwZ^XAfKND z<4bFNn5&*+URy6%s`rD1Z39&2f1hn+27!Gbpnd&k>!7T!&uaY^9|Spg8VyVmLLfER z>hoyfi$J0l$zTA!?3E#PD_$8w{E+IvW@g8aOB8u#!@;3p!AK|EoKpGPtGx*Tw%%Q~ zkEe5I^7IqJr+;`mZ}VaL-2!dirMflp3Ts|u{DfTv3cWG6)t%+tonIdkH*9+O?)=J( z1V_Mv+-}7dfBfBcjNm2nHsM zLR0awZn{$deNsM#u$0LdUUQ98jj0H*4y}uekH;mQp!MuuR9;@;^&(8T{QqeKlV8Xc z5=wguKg&s|x-o&}^6g4%eF(6OGEnPStln_Z{Nb0AywhKf+^cr;(Ynyj;$>`2n01OW zwX@-4RcQU8(s0eH7Wdhv!t0fF@wu<}NcQ9SFcMPf>;SYUS_6_Dpe!%FwF4XPpskHO zFY%7mCMmIr@y56}csU=JR4K5aVKf452Hy)|EGokDWvHj-#n-*g6Uz~ERQ<2@ruy|f z^+l(5J8~4jp!ecy6uz;Id+mt-IV+Sy7~i(W)^%c}hsnCyup3 zV33|JB0k+1Nz1b|I!1dpffk~!wQD`(7?}*`gP18fKlDT$7GDgrb@*3VTcYJb*(GJm zSJxY(YK@cZG8ut`DX;?c{DvCkhTG(d{%x_h;E5=Uwi4yUNW@D*!3mg;u@>a{q$teC zIRBJP1p1=@2d{evWqo?XCbN!rg_p&J#5qC`Ko_CGk=#LZs~V9_Auaas)yCw&0M&JE zXq>)WekseF29dI0l`eY13T{*o`9u9?OE|pkIhfnA&~;ILHa|s?Un!@P>;3A=SS~u> zQ6GjF$&tXKPy}3#ym|BH<)X!R*z<$FRD69W;^+Ni*Io#GwP(2Pnv5VCfS>lVA2Iv| zlM`S)JeArL2xA4+IZ*TgBd4sj3k@h82&#gaZJQnyI%>JhRaZFQZH#lJ@{_} zulmt$gfyN^UA2HnSRRqLYPJ8o@Rjj}@nsE>YoRi3lwoXH*9=*9f4=YdKWmq0>zE;a z<-uNAP)$(yS#3H$pUxC2bzgn^c!vBIZ^p-TuWU~pFddvh)>nDXLZWs53n4p}SL))EiJsb62%HGal;#rRRC zgO~Q-yXoAi>loL_UW`jo*jL+E8CLTwz1?6R%Is~~cA6ixDe6qr=>W2lwB}ErPn<43 zP&t1{N~?NF`HRUjbn?z^fe>kY`Cp4CRySMs7sf?ER)?k5vPubVpSXFt5*LloL3J8Z zNvQS9J0za0cc!C8=)p*#>>(Prp z$1i9S3b7z(Ve~@S&p99!U6%$KHm+D-8bI<2JG9mY1gN9yc62vx>?XSep`%c}b4HR1 z9(}kg+;7PQa@+=a;R0@r&YqMSDL+|0rol;?c-O`~nQeY`qh@8Yk*-L;>B$ATh1BvH z`7)sC4H-dusdCH{6fJY1j$#Ea06*e^;oqt%7GMfNm3r2AY|b-#5Xc#N~tU8Gul7v00%;n~}~VeNz&lY#=& zWKQAGgzPeFxXcL~#_@6SZ&RDf8s6S8;j?&rNSjt`ObH7kYKgEgJqN z$+zy&DWlO|ix0hCPjx@UUZ4Mw zCyA`8p*LIPT~Uo)QL#-$4WXlC)5o{B6Y^r7=XW|7CJeC%{UVbO2IWX>zX$@p$9V=$ zFkl=&6riU?B5~^SbLrgiL$EEHgBr7KE93{=M9l^EXtGjMQ`2$D+MoMKJb$#W;$svi zI&UACdi!$fxC$)qJY|b(e)K+E7uz-33`LhVEk8?xYRS$GL68cSiV)XC7a1MBT)X$g z!nm~fA%?)Xs*$s=uF~*__*2z~#?8#xmVGKMD1CL=uEo1&(X)3ia7G$~jEZ#2j+yVy z%vrEM?p<|!{JR<9@Arx?xnv!x69v=#EYtJt75SEYKkCrX@=^I#pZYvVSG4Cj7HAA{ zPwY@z>pzmdtWq%qRm;~(ap8<=bLt8oyni#!3ZbFPmdPnfVW%D2Ymz(f+_@vwi}c;edy@Aa^QoNI6yMZ_-T8iFd_+LGHl;hEJF0Zi zI>Jm2x>KLviEGhOI%AUAfAxX9+Pq4tK(-oALT3An_6w>V*drfpY&0S zKCDzwkT}$kfu5q0KOp*gmIvY)Wh1J_cfNr06B1`0l9ojAoAt{{1uDgE#~C8egNtH$ z+>d4Zo}(s;+N*FdGS;R#foB;He)NLEx<2Rlf%>8KVYRKb&!02!Qdz%n*nOcq^VC|q ze0p&7C_N;}krlQmc*DM^D7_G;=n%l9gWB#P2%QxyD(pUo&&cyE&Kg zBdkHP{9N;@ili`I%P3uetZ2fE8>kHi`^q)}20C6}I%MfW&xi!8J_3qPtywi`1y`F} z(MVs~r}du^XRb3@)>fQNkIy}1*!1Esg++N$xu?B3w>h{u)G>OjN9J!OQg}OT-~QU% zgFAMZF((NUU>PY1lMO#mM&pQ#F_wIt0$7raL8I3+Zn7-)He+_!@ z^`|Hq3oj9Z2G|tt#et_NyQGf6-}NL@3_KKzLli`JcbmH{`r$Vld^rI$9#2_6HCvec zcLxpZJ2aeIx2_Bmm+IlCgnwa8CZ`HWDu`H#&MLGTsdHuh{fHvyEof#IiZpnL$)I~-Bg$1JV@bq8$H~Hv zy?;x7U#wU#y)MFtUIBG19gNr_-K&5)qp+U=!z!s{@P%c|#*7(v+ICX`GK+=b!Xn*UuJKFK*f`<@N;M1y}&&vVl0I;^`gfW-xVS>(+ ze~cOCccCtU7_2eqbQdqSw6wHN`!;;q-DFG&2`FiVHDrVp2o@f8Usu?6d{C%XsiGZp zv_lb%bM-+8ZBpA|ar10uPGo+LMv(o%`C>XiR)Iw&);L(K^;+B7-EP}w>fszb7g4OO zKgC4HNb_Elhk@DH5@GjE8kD`>&4s*T4mU?Oo`bubgs2Z68uKUSZOBvRX|dASqIt44 zM}*}#qrEomg#aFBC(<8Cbs^vc12wAe+v2aqA|F^|e*VSoo}M1_-2c#9`;b)+teJpE z&hyO&qz-Vzzdb>Vw@#fr`BCoN?{nvTAJU@zENkz3XRycKtUhvcPGaI)BY9jUBM>O@{DI>I^e#}f9z0mJ z=%}ynrnIGZXOO$EmD`j7rfW}+Yz;B_c!Z7TrcHbD2(R5nCN>$zgS^G(d}XMF}4#n)OM})!-@$Z;6irE4-$C zhF_sSGE7!sez)CzX!q{jTl$o5^eM;wKt9Z!vvC`BS?ilna^9mpd+*-0YX{TbwoZ9_ z@%y)qe|39cfF!q`7yE~$kotwrl9DAy$)r#zDHKJGpQrhZ8Gh{Ey?YVy zKLEH;OhFXK#)G%_-iNDU4Md0^`|jLXGVONrX6@}6etNZ{3ix4B-)jeA(Q5;!3G zFf72}D#UxD0y(t2cz%82m(NF(;9>VWzP`Rn08-#kXgnOlKWss`E0=|?`}-M;1hC_2 z)ZMUWyQTQUE=yF>ae(38>W`zn&d-dDp zIPaQ^F)XG@K}EwoO2GdQ3S~GW{FHX#d#8CUzM|dtVMO6w65J#_+v3q*`vSaluRxUw zK`V^Lv^1?YekH>5(LRy=<4^gl`^o)?uNlWYKC!0r>3 zz!Q7as16neUz1JZ3yJs87tl{u@X9mjPuQvgWUQhB%(WTY8|Y1kJKg(pytXA-AA75s zCYX#01&qSI8oR|)jeJ^hadA(w9|nhlf0asdfHl8}Io`RO420b3s!K3+!=}~uXpBKaOsDX{)b{r`RQo2?Ck8cpv>%S@#y+bzl&USA{JD@;P%tnoWxK_W!vx_u17(sPPpCvWwe}5{i(LNB2f7UG@cF=Ztd#HSHpy`~I^( zWh8-8bvOPc;{A{3|NQ1i=4`p#Bu+$wlgKsx<0KVWaRw$JQyk)vB&&2@i*<~hgU-o= zWGMgaEX`b%P(`Wue$PvZMVA8f9FItrxc5y`Zk2KycHH7Jbk?znP(Bm2jOs$m3l10% z2NW3?nh3Sa($dn3w{AUpG~&85b|DYrKD9e8n)E3%5WjnH0;Z61*^!$!3hTTw0}Qod z=0S)O}BZHIZcyEx3i;|VAwF*Y045U?r~k~Vq&PP_ehh=lV3ame|HYFvpm?PL^yxA&Wm zk@$n4d>TrQ9^PuPWCoQlCKdHIQFIrNq1UOC4?!+!5r&r$l~ zLeJvf*ekn66fUZ$h+ip}4~-7WPK$wYiRT9TuV24@Ga2u1I*`z9Pq(Y;n^rZ=16d-T zO#aec+}76iB{%18%2EV(Ig1t&*CY8L)bBnDkD@Ig;)qPdVUZiBaX|wDEGgN9o5o&o zhNw7nRFWI!_xRCswE0&R{?+N`9Sv!|XxTrWJ$s&*IMNr-^TVo;K~y78nei6FP-Hl2 z)%0lYCU5%!2CBu4?)IJHHyDI>Q>FV`?~!c@+i@y`I0J6 zLVhohF$bX!iw$-8eV636m$j&NYwD!6H44=VdS7Lde6HJyz6f5cPr@Cz{LpJV>s|6Y ziPU-AHSzCo!JUeI6GaPp+#}(kmbm{gti84U$kD%=<4Pag+!EbpZEr7%!WxW-y;)xP zMP@SsZzJRv|L#@x9yoS=WXIN+n6`GQF5J3xYvQtH%Z@JnW!KUIoJY{Gx@eR|j*XQ= z;@sEz0v3i{aFuK8eJ;KweslHT3;V4jNR~4{7cF?Rqg1h>MW|wTwt%u9T(an@STeTA zY5VwPbnujviSdC>OC6rExton2ps`GW@3AWBO*_&PCm!`8CH-kz`e>8e zB8my6ypxW3}K*xvqA%=EgQ zme$W>j(i^SWas)PZ+H1WT>0m{%(1Zy zbhPfY?EQ847kdiO0?RQYK7DIs+)t;)|2&hg1m58m&x*^$^WrzjA09oOt2LvSNZJNo zJCxV^_A&ivu(@R-h;3L~zxBu5yDA52=hWtI%cG-Dw`2V!`HDDd=$nb+Hw(gbU;8~Q zv-|@+4K5blYj+oW-@mPD)}xG0#)K|@6`$vEzpJ8hq)fW=h&N|sl>XInrAdFmz_;HQ zqw%IWL@>+5)B@7`Ri+G5)kyx&_0N};1h&lCrRS&iMsx?EA6&pK2;W5Lw+@!5+wF3@ ze?|Nf5k_hdc>=`_wMBHZmh)Jqx?}K(+`nz6Ebe)n#m6J>J%I%)K?b`3Zbp{AP`@7g zv1j6Xf4|H30p}ogJ09-D92mPi+!{hNgY(HGl0i^>BMQgW{q^;m0KBGSUp$gY4M2*w0jznhLz1`$ z&&J5A{`&gNP=2k~o*32|g66$S7cpv5!OzY<=GasU?sj2;6-)eoeSKu0I2Q7qs<5YQ zp!O&BH`SgO275+;Cwgm2qx6vXIHDPVzsNE_zP_<86bE2XA_coY;x=)q5ZWLWmzmny z(^Ha3>;i*^o^O9Q_cjv$7CnNm@2{*`(j^h)JmLkbz$K|9f>XrXM%=*VYf?GVB)0+wRTmIGU0`JXVukry*~dxJUC)1c}Lj z3?e)Z#-vN0i_Wv?tz;tq<7fBR)(~59nR#?3Z(-$7x3(|0-XX;pR~1%#cSi5f2$;ZS z5im#l3D*Omp3FaCKxkjYQ)udEgNQ&oCH^R%~0&x{EW{1hKt;&C!e z;tUu`N}9&NW8Aj+dtfsHB-%c#6)*GY1jaJS{q^;SRQi%swzhY6YObB~v_sE_OsHP> zW&&Ei`8NEhx2Nmkpf%4Z*1!Zz4gw>q0RM#l7XmO~wf*(=g(ht*8X(xw(@*75uu)Id z8lBTvs|_}#XKj4<#3iTe9E1LiV!LIpKntE1mI4p)j^_UQ`ofwb0d3R;@wonL&P3kG zz^J3+HDRXQRY74pizcbl-bu?Wc`oKb5l~jFB!7KQOz@}z2Y zVl}_KuxREu8i#d`OT6CS9}C4)mbGCc`|Ilf=3Z^l{CqDk6-ihGZ7K-QHtaxSMNBPV}QGV3X&I>_t(}D{Vi*+@|ayHq@?poQ{JKV3b7(>J#lt{;(6wLFTQK? zRu{ako58=nzOdBde--Yt#Y&q>94MVEzl1ejTpysnIc=n)Ob*HGRES3VYlzAzBOwqXbRI@$kIa;o0gfC->LZDn@NCK{xKq z#?r!DmjisMqRlIlg@T^LN>H+4V>9|#%~~qAlD8qiO0Vy)tXOuRM8j0;Iv$tMHz83M z1a#M0)!Z_OnT+rNfFL-jt|lJ5lHq*X z{QIWI->|wL94`yzi2aro!Tt61hO*xjN0#WBL97w_O9XV+CtfK%Asx2~V2){k2;6h_ z3zZT45rEe3@2{*?8dW+h&BAko@Bc~kf9iW9qzvaK3}{8lq3F3$87epT*Va0Ypodl} zjxbjY;W@_P{jEa3Gm(QCKfb=Q4x@x+s~u3NtGDd$kz{2*60dH}}`pA^RhhzU%-MqRk~>;Az%N0eN7k$r#4| z`ufDSBLMVM-w$(Ow>alel^_QRAmv}*Us#D)KF@$m7|6gn$iN74tOFza>+2CC2z{76 z!ct2N%3vIoBMBehUs&oohgm0ttuO6|dnHE+O`iiG>!e}*_4Sj#8-<-(OB}QWLj=IA z5Nre-V7^8G>kx4bdOyCtvH$oI8v&i*#^B7RB2L@r^Z8>^pW#?2r~8gt2%=QZQpjD*pQV$VL=FjA^PP(8B;JW=tP1-l?v~ z=m^BZhu)8-TCfrQ_4Sy809#eBStok+D8ULEHhDWs%7F2qPut$KsBiDDtfPzx0nKW6 zeCzdxi!pkq9*kLS^~d7$Poa@_`6PF|h-Q$=O5tU7Xn%cuU?T|XGD>-X9SYjK^dG{1 z_+uJsyWvz6FMpD;^q+eNo2S@3BL!Fxg-tyE`uf3E0x*$)cp8Rbku6t5wgp{+*Wc={ zeWvD9ysmA1DY*0bYP{5GNV{lb`MS{pSwFDT&#yHck|Fn)b;kCq7Qj{LCM8eEb0ur; z*YlZvvoh^xs!7`R9fHH`J*$m@x=!Eg?kvJPUtR}Tz!d$tqNnb{!`kvcu*l6|S**^Z z+3itGYgzWektK!+0 zMXyvU$Sk1=qh*Avz{hg+{q^*a&bwMN5?M&@D3we7r$hVw>`6Jg+7V7~Rn4!A=Jc4B zOdow&7AJv`t|4j$BgLT;pV%tMwtaRX#7p1iffQHVnXLW$KMJ4|rKVeYdAik4)WwND z=0WC3VikK*#K>XD_8x!y*n02m6xm2yu>+5;WL#^n!%RnO@1|m2Hj;F^Lu!oGGO6HB zvgEwvwO4g9SuK9@BGF;u&tPpa3!dHIp4$qDUHnpED07NXHTqv&OW8F@+t{_~m!k)O zv+_*cDg87|CT8fQ8>>24th#>KG8jApy?5-`^at@UjBZd9y@IImf6mDyDgshbwsQqo zY3J7Uhsf&wNGH=VFEHLq-o>hFPEO%W3{^U)LvSrm4U-M*Pqqw(4+6w1!B_j(cYCiY zEC9beHBz1T|HDf@v*ob0VXRO(`rKaRb$&Rt#Js~x0go@_n~cX2+RO5Kr0O}in~FTr z-pm4vbT>GOQwM-~<}e}Hb`XiiuF>ye)=I2Tu3zcb99SxocZ*CWAT0XaSF#pI8+IcGMHTmC!+07b$*Lc4Uh3_(t(UL(0Y!e8rf$^nZTo7E z5^jbaKYdvTotG*^GKr|Jn<@9yiQd~pzx{cVg(P6EuikdJc~OJhEuUKN=xllW0?*x( z4eC(=927}EB%|hTzd`?vRV5DXfarC!s;FS1@{9%_Dzb$?`k%t@ zFVG|2)c8@prUI|*!<9Y1&SOFpqKKah^VGrzI6e`LXWm$OYS<0 z(ot=$HucI*+CdZuT#XO*e8Y3ABE+K@GNC`_rw2{(TD&ZtFTJ8)^H#xGMAVI8v8V0$ z;~i(9e@tuwAu9EOYzx0B+$}r>nf|3IidkX2#D6!Nh}&Ap(v(rEqiz#VuGcQ#rqQm? z%62<@duw!at043H1Vir%+h}OEkLC;AKWjN93Lo>TVL~SJ((=PxHVgt|uc!{$+XzL? zg9msdH-uC0G8jsz%NE0Vj2a~6M!Fmnhpwg|Y!ERteN-{Fd9)6w2(T)>>R>9dD8L+_ zlN2#USukG2>FPZ8qKo?$mwfGN#%L^c^)AKMHP5{;0*G$|F+v!*&c_yp3{?6s-eW$J zWaz+LP_mVjf@dUROPtv!2hif`GLqE*Fm+@eQHX3Gpw$98ZwY0I)6`TIU)X-3N;#6= zZswBv4SgN3WC*$W;>Iu#6Al{&8?8Zij;gFIm@h^n;K4&wy~TAG$C0iaW*kiZ^f*)Y zsO5vJAoKeG^zHvODSq#KTJ@LQFIX{4|I2_`j;s|>1eWUsXP|yyWeBKz-;fCa#IC;BR0XDBIe=syG?tW zeM>{D?PROWu+Ueg$%^+)%_{7<*Vn_p5H8riY9Gk7Hp{-Hto!OJ%|Lvz`G%+vQ~z%e z{V(;}=F@t>a-Z2L<2?V{6@5|GfRxvOj1^|D>CRI-Hv;#mi@>`RhWoNKd+b+#-1Ucc zE0RV4Qg@Jc2rzT(x_XMT)Iw_z!&1kJ=})j9z5R7Mf-|S~F-vK;+%Z?x9m;430k{au zhQ(D0ei4@}nk)XlcJ%cy9wQp0)#Jqb3VA;e@iqF;d0jAcn1h~Y^vLJv#!67jwUPpwUmJ*l;RoXVlbbznzt%b?2Nf)1=TVygvXKs4u{V8rPS%q!;!?`+55Ql0}K zOE&$)5?MjT84hj_Ze`TIu?&~>R3NrdUhi$qtje<4;(lpF}e-f!Nk?h91Iu0NPg4n}Aas?eIvwIEU$B z2`I9DMf&#km72v0lWJ_wEGj~cG{O>PL_Yf%pCX1BwU1(ClzZC&g(F&6RrxHcrZL7KJIE*oiN$O1d=cWl1Y=aRwe0( zZp`ho22ck}V2%Psd70xr!7na(-kBs?{oN7{W()}?R6{FjfT6-K(i#R|s@uoKJk5mx K%QxxVEWd!-&k-a5 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame23_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame23_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..3ab6c8f592e46d9debbb40693527a329e22ce7c4 GIT binary patch literal 12114 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 Xs}9L8TLp~0tD!kSCt)C=FW`RxE9a|5 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame24_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame24_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..1bea13b50effc3cb4ef48eace11587454d8f1887 GIT binary patch literal 4156 zcmV-C5X0|%eP{hiBh*8r=o!f*k|mOra|#0|et&&^XH#%!9vxT5nacs%Z&iuDHqUSNd2=lkk~U0fYzBDe|>#opd?_lLRVIU`f}!RICJ$k zzFqB!MkMFOnR0Oo0#k2%9g_)z0-caY-vpwMVuUl*R zGf0qGBb-hYt%L9*@d8p90^Kb=Q(|?E{q^;MJMdeuC8?@z+q+R_gjX9gPGw8}b$Sl> z=GzKd+2Lbps2s%z;yl))(00#5{5?UbwbIFdeSKjdTdCJbVpZaHls>7_=uCLqHrbn` z64Ig>6OgXJ2psU@U{F~X2&SwR)Np2?%(r(A{$;;!@2{*jZ@5^N%2oI8FEA*f|B3l1 zq5Z?(WrEgp-?u+@is=&E(P_CD3oCr|9jgLnCTa}ewb%sy`ufBE%8<2EY(XjQ0Tlv% zQlTIXL|iN_$A0~lNNeH)EN-J;m!f`TNlzkPYWaX^W*MqT^aMDs@2{*Xf=g=Hd3mnV z17pHli20Zzk2V4&Yx+_znTuWKaT^~W(Z7JApIWfI(5nFdNSyjt*e`|s_4S2AdA#mA z%gtSG>;6XmOnEuuY^|7d@i0)t+dA_pdJ80PExxaV+Mn_Ns#fd+A_wHB_t(}O?6)2E z`E$@W&-q~UY*il{Uuv5c%rpeYKdlt_=M$Oqew$+27>RQYjPt5a3aAujaii)#r#^@hbj zOy(@YN)X#69_5eND~df4Lf!rK^@&NQ%Qx&pR+KhS=FwTEL?!N6tu~}xS$&EC8W!PF z>C<~qyJ>8smAm`v>lGlc3--?jPS@Sv`@Gq+%qi0@$>@$-;Ar_*C2E!YM|0g;Gr@2{*xgNz;#QZ8GCkbm}VZSxO$ z*l>fZ1z1M@`ufBnQBV#`|S_D2=MYHO;lH zDAj|6W2^h?>lH>pgP^eL zLFFO8zP_>R53nKRUmi_}m4uay1RDU0m?_i^jex)5uo3Fd~+RGam+!tWjD~_80^%#l|@ENJVzr1nJ``EF_3Zn_4S&! zLjax=)qn6{r{e$;&1t+}jV}9ZiIy<|mP$yrYn<(4`|Il!NX76BVypj#G0Q^@Pq1M3 z^aR%X292&Kl9-Z-Wvc*u2MGT9`o=-9q5}+%N&a@Lr()<6^Z^EDzYPj11NWtKJND+; zYQf#uNdEfz%~Jq7PU%!HQp{en{x)>gU`ufT`uyz#l4ASnE zxZlULD?*?1?XfHh+HrnCX@2$sUA8)~5`9d-Ct*x~eSKi72-pU}T1)i+E-_WnEm!{x zw5Jlo{mD#xoL>9Y+3!n5wRO{O+7P4?MhXMJzP_*$v4n`*!dgg>I4dgfIop^0AAz0P z{hLewBv#2jMcOLJkT3nV8$VPi@a1!1L)Z2iN%iD|l|TTdgsTdXJ-$Z*Q&O*@Gdanc zHI<6}nq;%p7J8(MIS@8J7YsE1z_z#3*G->bhd>~;N~)jm@8ldGf4Q6$`fskEfEKHhaw zvUGw=NA>JUESA4(IUd^+z{v%0$Y3gmlxAJO;-*SN>$iW!!*lt6b)UYOn#JQZb3=a} zCqLu;rlmjK!o_9N_tlVj?eS%Z6-r!)coj@npRc3-ibFkHzPpv?C%@)|zrHTd%#m)}-H1&%|z`Kb2imWY26DaGEy1^rN_|MM-p%Sx2*vij@% zvJtnbqp54tiFt>o_m&NBbnHqbHhD^}^D z_%Mpme^$g9FfS}1%k@6DTR_vDTX^()rpogbC@`&~=GmH|?BeWtp#dM>WQo!Sw<_rA{#K_~RwG=s2z|Hpqev7ja@eQn2lg`VMB*5UR@*M(*pngql8w)|gmEAB$nXy+%$`yodJ(vz_I~=NLE(B|KkYJH4?v~_ z_F3Jl6@78`uq^${CL7Ze*sf_wCDg*RSp5*SRPA5wuiShB-_YoGr{h#GZWMFxSr#Tu zc4c>?+b;grzkOK;otL!#d|EZJ#fIZ6`VN>w&XFh=*24jcV8~hfvSGYi*V3qO3yIQQ zU3FfFpYef+6fFut6zch*QJ;}YE>`BTXCU&7#;(LNgw}r6DOvk;BlZ>^e*baP29Guy zOjBzPRQ(^9Us~!vKlb|yE2hbU^)In#f2yrc(xJ@pbx?q&2Pb2R_u0tCGBZYeW7^>E9R9nSHR{zYLM|r%gcn zglW*-SF@S$QmYb-U|j@EzNldfvcCp?uzOz6olJ z2tW;2z3`6pmsnLVX}JI>chbXoj2eP)1(Tn(z+K%OTYs?>OAylS6uwtzp#>ZqWodFVGW32QJ*fE6Ba<6JkHY&P}fD;Kz_QQHc zA1`Yg#3^%ko562w9KmHNq)EN4#sJRhU@G%0JJL$P8%xEKjLw-p?*Dmw{=q_aU(}PR zcUbE^7!|`HO&2I#gAI=j((jfsz28<2+)S>?hV+maPLA~O{3J11Fk)Y271Y7i3ca=z z>}ZS~x$k{CUYCkqpF8X_zxw`XxNNCF{)-)FtNIC)CX19V$%e=JoC;O^p3i76%~fD= zAG;nQpf#phx^acgG^v*X)Q0G*(*(=C(JeXVyyK{ za+#Mo($X3i!|>M(#PvoK+pye(G3=ui>`R((wp{)u7ycY>yW`2&e{$QvwXFN<$gwmp z|C@anOt?2p{Vn74px@B4i$(pDIfX3$KeAWVgE&Rb5NQnbjWh|2IDbB=GCTq*uj=WF z>3tI=@iPuc!U{pis?_VADEZ_$El$|vEQ=O4@)I|1G!A$ z7de`wGx+RPzu}Ua8iWzmZ%YP8fPxn)|8&AiUcxZ^h?enqa0XcADdvA-)Yc~bbDj#T z3^ftn+?U$Q50VPHBt{bpQ?=$J_rA++gXV7jVPdawq?phQ(N}n2*x* z-~T5Ti~d^(z%ubuAmx1)!$?fWU?+PwxVd?iD(}?N1N74L{@TqO45H3XXkN?BC+Sj$ zKEZnvr|!plYfZ^eUm2^|>b{iH#D14J@}K)GeRu!im~vdBn$p;5T;T@+7;oDrxDuU{ z3_7hz*m-C1I&$ugT&97aB)rgk7&cA=fEqpQ+L~{4{j9I+Ko#$tB1>t+GFMa9BN6&v zF9Oyr{h4}>PpH1cU*vu|swsZJgEzEXg z^C^-peXOFgOJU6i6yATkB~gVk+*f&o{V(-de`2hcUtq$p>?{SPx4o1klk(UY24Kr( z4}`E1tW{&Ik(5!qh6C&uyv0r`|4fpV&GEB$6R2_sxuqL#q`>XSJCZLlimF>wW;H_Zi(u~zcL=C*ce-i)den;iIPxRY?qU&sr zg-j2(EvO`tep?K}j!KdoWb9a7b`S?<2ve*o8%e((rPJ0X8J`B0huye)OhGK!g!#=0 zA@Akdvk{d5Y-12in(k5`V#aBb`ca^Is0w}N1LZemc^{ZCN8|s9hX3->#O>RWFsQ&1 zFfPUbi~%H)WdAJ*uDWAksHNex>j3s{wIF_sR_yC&b{Y-e&^g< z9w6w{uLrHVUD|Yqw%z4&y|^jyMF5fj0Iul-FiaK{LB;;^r=qyYJ7$K|>T$nEf+()T zF)tj#aS#YB00pan;dl<7iccSrM#4HU4Y*LK0uZ+D>Fjd3o;@8SisB1nz~kPgV>sEl~uH>}`Z0Ei>O1_C%? zOv$k96{N`;BXwK*RxoUNfoUfw7=C$!+qyh}VtfEzF$kcNF&0+APj)V(W`dL^^z#Ba ziY0D_85Mge&>2DISN#!^v2NT)Yb6{D%s@KB8yTd9lb}EbaDDfD&wk&p30e5sQ0OFp z02--gg$O?-&>Tz$I}4yCph_50Lev)9JJn$t%>3bB;~2)2hib9o-t(M#EeHi5wI*4J?Hyjsq3xq44bjHx7-DI1i&JTz_DdB*%cXIV+f(8zgZyAiw)3^K(JT00Arkwynr2 zxLjTBzl`ib(*lsK1>Ts|{B`EM^(V@dwTzVTGcYJJv`uC(2zI;D+p3mrAt7&`IkdVG zc_6wQ)3zOY^xf!&@;A!k%crkzI!j02hnO+B9DO}d4QM8uz`A;O+iCk zUtG>9?+pk@)5Sa>cf+3C^plGQUHd^yci7ijecoR*wmCAABuPJ#sshS}L$?LWnNH}A zJ|!V(Ckebit>AAQx#3`LxIZ6?>R7haWNPj(b-A3(dI}$87F?q^?-I4dHDFkn{^LMuWzxo>;o}QE?zMkP4j(?uZ?Ss1 zebcY9Yb*(Z?=2wV#FOjm!cW#UU3%~GrR_3nm{-M2NivHXAmo0-q4ckF53bnX|=2MgET>W_K%b(8UeY3{(ntI<)*s=}{ZJGALYD#L%nq7oPsAO}vg z3KeRU$8R9#fMO*S841FG>`~}(>5XGgABp1kj~+czt9g!F5F14VO!$@9&t?fR8@jXy zr?l%c7we^q{|A4^d3aJU2Oh{*Uwu$`<&KqZ>(1!ZTd(K;e0Tfhi*|(qcE+dwFl{Iz zA-*X&cT1=!N~E`wSc%MXEjm5GNm3v90`eM%5_gh}l z99WcGXs*p)W-{e1o2nc;E@E|ZW?61+3{Ofp;7Us|ER2xoar&Y?C-?0+UGH*b_I~~x zrGu#pEd7qrvK6tn9=w`3XU-g(tz1;b5&^6f1U2EcC>|`9k`kzjt2!Mix_Q^!ovD*XtqYL~BB3r|js(aNWSRkHE07*A-ipSuDu}sBrdVO5J zra)r@TlMD?{X?wcUae=!0;^B&+^w(t`$0_Bk-<4)Vxss9dfQFI-|EOZ{hN7XQBxM4 z7h((MR@keU>Kk|3Us>nxIwqPvJ(Fwofx`HyUuP=9PrFzD{rq94vy_jrQMWN=2^wx2 zY+mRg?i6k_&j$&Cp^o(D0C?Mu2WdM-o{Ht=Y53uf!oI%4VF?kiYgeyTTNG|GEirbv zV%dlQT45>6PnkDN{Lc@u64K94s3`>&Hb$Dpm}uh`B?*+kaok4Kl+D@H)l}S;t)#|E zUdqZfM{oh_D)aRDPsPjEOOAe=FOm2uo*@U>HQnZ!OjQ7tlxtYOqj%n*Y4*8unx)!3+4W#ReZd6G}YoMTm1^X@Vxu)Z<@8{z`WWr+fs#DF-@}*y%kv@V2b=< zz}&7~DCqs~i+^A|op78ZcTSdn(3W1OT-qH2YP57DcqN;glbSarH33LFD4dStijR!z z&okgR0O*>amzN4MKEk}tu*i^*C6k4i8;uEhrkcDmvEpc0pPOytp^dlBZ_(CHzMZ7o z*_0L@7qsd^HlL|GU{o{{A2Yz}nf`%+f$om(iN<>4O!pIy`1fl>H(^LpNfHPqB7on` zwCScNTfofbw!3Vtqwo=aiMPQzj+E+~Ic^$X!eVUxbP96whPv$rnXGpU? zk*Cttp@>w_6fkhf0mdL?|)jE=!+qfdVkJ6u1yenX2A+vq~eG>b&*W5GnwR{ufy z@(`)|$%%P87y=;^#s&tX0}rKo0iBDGkAP0=4XUdMASpytJUC>0i4usaM3wf2+f6}i zqLgDlawCLbH9n*Sna@(bGEA*owQIhiyN>{mtVw)PaY7T^Ix;~ViLDlYk$>{*jg3%-z2x!sb6;02k(m?LtO^G|W7DL*NzJibEipMcO2Brl`v7*w8DOh^IS z&U!_G(30bTCy+MQyL2T9T9#KK2>@yo_J>jvIOVPCPS2^v$wm*-6G~^vlzqVJ3cvuB z{xoQ5il!>PPvA317B9m^G%Rw3`q2|Sn##)qg71;^UROXyWPUE&79AB6WlPwlFq88% zku(IMWN?s^0En>zLEz z#8Vg?B@!TzA*jZukfKlyA;HLOg;GiS0f)nhP6a+iRg*wCQ5JxUqE^5T_=NWX35C<6 zEnyB467hj(fS=qNo`}W!1k7y*h{6FZz7T7B7uqZV*7!`tte0berA_BKfVn_!)+6FKX#oEL0FDg6*XHaPn(0j$e2I5VYKymOE5}^$0 zo+qA1nC`7{Xr8Z>a^YBP^wang<_0WN7zrRTEGytxTm-5OSPQYZ0*|y12oUhTfIy+0 zM6nU19>0Q`^3uuKkXGb~!;xAqDVA!yi}(2)58qWXLfl-@qNRjDDFPQhxK5jnVByM{ z9JIiG3E=oF5EzyT@IFp?fkXvm`4I2pXIiCdLe6k5ty4>3jX{Byix8CE_0zFb>O!5DMXr*(Nqe|z%!t_VJBGBsY>fz z#213l<&jYy2&95Z3&l{R;<7+wf`TjgccuS*;!mJt1iqzhszSZbrUvN(!BRTsXy-P3 zUN~}y-!k-G&sdZO!|EY`qEsr3W>`>p{hUS!Nudu4z!*nIL0jaJ*&V&saLy5t9?)q$ znT}4=WhJ4+V|IXvLk%iBA;?m1uP@r--gbS>qWsghR-~R#%DU+WuE=UGfEZU5i!e1xQeh^DP@aqh67|@?*^mY<@jKxWCRQ5)_KH897~Xo2ENQsTWu=#vqhi+4Fqv%DjdX&&J8(eOFPB}3P>oMke(psQYaQTxi@ zT~^OVlgs6PHPiZyd+gkR>z3eU zgv*pX>f)uGh!yTRQ5+@o5W%7fFs^xl0|hFt$)i&#PApY+PJ23G?eYL0HmEtRiWjz? zEV=T7baT`X_J)t3bHI1~3Xl7r(Tx~vx?C`7GIf%{U9rzrMxKT4kF2W$UmggN0_=wo zw4PEJ#^|PB5OFX+qh_UUypf3Fq&Sk(a2v0O9rawtk`lcgy*$#3XbI2&iDm}2p3*%b z;hA&Z9svc`D=-!YEWZv-EpDn90RP^7E5>xh5}l;c>RGAtxMkVB*74X?l4bi;?8Z{T zNkTphtZ6-{mBCjxGiomM=dgXgl3H0AmlV_(f~;~#)(CLuvB-JBF9~3iNH{yhHT9<* z>5qN8XUy9@;TU32G@(d7_MQgFjr?vBa*(qx95(y2ED#nu^20#%Z~!s}lpcV$ z@I1`ja}~};eYDPIvwf51n7GnIbROCJnxr|#n~9yDa#Eg@;&_2_6%sEzU$|zR&To}M zka{vpCU;VB@@0>oT$f0-+?3<3az<=-pl9^V!*}&jyENuRf9Kb0U7yZ|u3>CY}2z6b%BE z#9@vcO+#Jmh&^$k@rK(fQU59zecnU5`q7G6m7uD^aTa_n{mT%d)YYCAr@itbbngi9 z4bue>P9=0;fsG4SLp6Jz!q_Pg4jB4pMyZ0UfYVFz8GU=ihcQ*1%Zv#LG}P|5wacFC z7yLJ7O}tx`UAR?yap%~No$FP}?)*=rK~fE@9G+1|CI#}WE-hMpF;|a(aKn_|-b*`f zt^XnZR@R!RU5*EaS4Rf?J8R$S+VvL2mEQVj;?JR8XEviVcSxmD1_I&Lk|F&6*Yj`9=;jN``O(wt9in^Ev+v{o=t7 z&aN9R!SylCVJEW3UmjcZsp?|BTK>sPrc{GLsJgN8$mc2b9Hitf$}-oe*GD92lB8=# zYF354h}}Xx2nIealko<9lHjYiRBo2baCY6!*?{cmM$u-D~&8I zU*M#Y^-|u+*|4V=q*Us36pA!zfbbnayn!~V+9x~%VNMyxfs=T|`xEt*-tMc4NBS9% zap2cL;A%R;9Au2-fwwE)a8KfTgLrRx)gjjqX|d7gyXf33OGcW)98@1)H+5sw`eq3) zNzG1CX1F4Iz|he-)`RMeE3kv_$<$9M43fYN%UjL{2IF)PurdL2kqByi7>7*BzK0f2mD#g0uh z6cSW!`7Nqw=%6n;ABpLqg#RG|y;-LZVU1(92CoiW{zi6Yl4GT2opBmTjcE&R?R9>j z-xwe>7dCaKjcxtjbA0^GV~v6HR;8|*HkPMavTCww<7+ODJ2K<)R^coOjgaH>5{=Xk0n zr=N~JGsbl`wfmyIt*$l7P*k^cgKm6@di`QU?-@s=rt5-o#Xp?-;a&&*Z?bopqQwR zrpT3dZ8hH~{KFE?+e_S%Eg__IJ=a=LK80F!(C|WL>=M0J5=LgZ0_!6KB+1>=&n~?< z@so+Y`56^#b<-Ki?EZv*TVt2+ScD~ms_es>TYg*i^(V6r+5b)qov=b;kJ&B&c0`7$1;{f1GgBKphSPr`tt@u5@XEG3We)`3`clUL4?%O9t z6SDcal*;E(bX(#NFKN@&Qd2jx@l1X?Oh1?x88y|C(oz_g{8HeIealY&=i2c#3$J(d zD9+H_lH_l1y^;9ubqlXFeSJx3kQo|syEYu|T(xi2z76z78{3*W#;`=1Uw?5+T|n(8 zyHuC7wP4n3YT0FMvLnB(sMV)0@?_rvSHbdx)jb=_*X0>PQ(6}_7EjINE^Jju&`WFx zK-tLQ?=>1=P;NvM$H9*lCqFwbF8=71-9PuI#Rm>7jxh!<>Bzb|^K+rZvox+^B7`}M zxX2W#(UK#-N*6t6^xvY)>oi5@ZP2#8)wSC&-H;Sf{r`e?A77kg%|&m6d$8S}pL%Gy!^n!D{At3SpkAv>5m##fMnl z(+o3CrN{V#a^Uc7$Ka5F)-y+jp$RIhdFGTYQScx3bXvb@Sv~vdZ%^*;0Ac%rt%ZDP zT1|}Hwd542ghvOMBRP_nh@~qlX7H&N3t#E;rcZ`@*2ytuMYeQtb8_3EDTh+tI`I}E z&zm>DFry;r%;cdPzcvzBRjgOBN~ihblqFPd-t)?GNGJhxUTN zLv+fa%*-Z7r>QopCo9rmYT}zpcb0Dsd;VVZyN>oBItZm+Z-eTcJ71sv_}*<`u+F2R zA&R6V6n#L39No%@bmc5+t);sVpB1yfuYMl*Rn=#^M}+poKO(B#}9MJVCS8qB~7=PN3y|H8 zG_{_wvHX#5!zKyjocKTDunCit1W6nUF?HtL+8o;!y6jXs2$H(_Yi_!Iqizgk_<^~Dd^=tiaI&m?k36AQt#(Aj zbdMhw7<4eq;Oq@Z=QHG{JT9em?y0$_la?6>#1a?@m}*-`M|JhgM|Z#H`7O1KTBx2J zTB(@0VJ;EGX=3+3_rL1688aROmjnz-FV)tq7lhe@U%N$ySsG>I4X`kAgwR1W0zpZT z4rD;lK=3F%;iUjYhwFKP1zrE1@uyCt^AxW}24fB^5-_WoFw8@!3l>TLs7rRTH zx4hkkUp>W#-Jv&!uu&$^4c5VWWvoK6>Gx@Ce=o}muYB{X2H)jETfSz7;cD#$Va$2< z^5Tkm<;M7pk{n5f#1VTUAa-QhA3p?=5c=Uznxmg#4U-}xUyS?p!iCye`9w9*TBTu+ zM|kt^yE(ySIUme;{P;ExDuxIKnoCMb;_yJg`&X8Y-yG*vxE%!cjuFr*I6XiQAZ`IY z7Tmz!eP%O91-yllc@;02QW@s$`=C%%6j&^rg@%Vdhq~o^=Dhs)@h=zSe)qybVeRgD zMKiNw+k*DLA(>+IoEo|NW1u-|W9o0rec*BdJ^#*ZoSN>88vSdj^8)nb z7fkx3=TXst6EA!ExM*%#!k8S=P9hMvY7 zK96em=K5ZbeEGOqee}le6HDF(61%#H`ms!^Uc_ar4c+{kZpT&ZtrA{rM(9et_CN`| zlAea2eiB{9VKT2<{2!P8;j?COD#XU#G|kGh51rr%uFDSK0AX0n!?puwGkub)j^tyQd0I zex%qyt!_BmaJF#U!y{k*ah>t?_VuL#T-7Wb2s~}a(+VRaL%Na=%Sx0=h2rVGySx1L*yEou!O$oL7*Bu$W` zU5j4rAzRoG6F7z#8w+7S?z`@ClB0Qj?$zOAEg!fQ&?$-|+IxlDZI*4V)wW#QVw=A> z=drgF9g9gP0sR9n3+Ln12@5fHgbt(vBmPn@;+_PkBfGoxBiF878&F#+uitHuWwGyH zmEtLxU}b%eLDc9qlQp zxg{T`-dPzPy)ptf^GToXvEe=A1G=^a(EA#e_gNP~Rh51=Y6E-eyyW(^b(8B_#z-pW z)=g}yK*a5>C=e3fm0?32ukYdVed|}s@kRjKWsUy+{&FQ7qKslVKJ5?iFoSXUuVR`!_`O8Qo9NY_q8J9pm1l~!UK zAArIpSlZ<~m)47MzhWc|zxAZ7NJOVdKR?&1a8B^yh&2;kt|}tywbaWtThp#xyXM@A zoRX9nl^OB!)rsg&lumblQ( z-&gmioybbZ@NjbcM^;Zj)QYHYr9urcm)-FTg2@j(E!|!b4!=8$kxSfZ*IvL?x~6!g ziMxVuMK*} z3uu{=XT;FSVPM(&`s=ShNS^RuZW!*FMs|DqX;Su?zZ&IPvgAx#XheR(y4?Qd^XIR< zmGxWH5(gkU#229le-1DkS#ag0E|AQ?zdzlN8UP6kmoE?6w%zF5CYNBzP>Mn{Mo8zv zDy%zI0Bk?nKv)+sHXL@kiQKIrDgQZ;Xv0o3=hu{s%>ASzrDmITYa|w5xZ+xTdE1CQJpF~ivxRn6VOLw$)IRY*DRJjc z2>!I4TZ26W7*_2a9Raz09ZaDO{c(}!j-5Sw8+|w|EG)YLzHI1KhadX&+LyHR*g0|a zv2&x+kmYM++|q-{becp7L%eqz?OF7&0|^G;#dDhy@81^_H=S#0?&u&0 z7BDkWd^rB)fM*E-<2!awXe2_ya%q;g+6{Ipoy$8pQUozxM4dm|&Gn*uZu znek;AWjzPCh0h3(V0aT?_BqtP0tg$OnNh4Xhh-O=va-zQMn^|UBazGf_Qf!M_?Y*yULo}`E?dU(UbKLP8q*Fz zq24H?t4V_ERZqVZ|M%SE7bp1pSih;V4sP#=iZWqu7RTafRXE_~gdDbcF-_n^z+@CV zdE7}1W62~|d7eA;wrfk+?xSPFqR+VRKN%H8O_}E4qBBiC7{c#jd|KK~ep1pkeQgi0B>nd)+N=WInhnH=IKE1q;KDpO0zt=!RS@90H)N|ly&yJdT67lI{ zNNBJfIB;x-GAw+OLK+={=01WOI=G8EBT7Hr4CIIjBYsN;rU0*T&WI5S$jU@l1>CDg zP7w&V-^3y!b&tEa&mF5&;!gw;hjxj1gh1|qg*2c*cs4J?*;PK+_^`1gayuX^W#7=h zLDpXXMFMpW+$OrE58fF{XgG)(@GF$|qFe-C?8Y>*WW0N|5?92yd86RT-g6Y4>T|MS z()))F?cJsHoAdPivC*#*zsIdFzfFmv7^KPG*$G|n1-)%q-q8Vbm}c9R>xHqd&Mxoi zg&qEhiEGy$J7*3FTkf@EgeqP~Z$17krgoDciUHpYiQ;z@Ls+};K6qUni~~ll%hhsU zbQkP#6#I9&Iy~()NlTekIM_g(2bF;Z4$tlKX#eEiRON_dU%;V8SB{IL^o1Vxk(-1R ztp#5H^_bv0ibLdJ6E~H8LUUX*?{>&aK=Zfoph zmp$b2Pq>eP{nRfS9uHPnZz>NT{W@&?(YW<@?iUw!cFID=gqJJII|?I0H1m5dc=lUS z_9VuK$Mb)CQT!4uLc2I=-NI&VdY@Z9{LpRno~C%CS&J)U7a?-fl|O&?6@N^ey*vnL)2&wxh&XNobOpmo7%>rOm9` z@r!2o2wDstoA~{!W&6%c{YG)a4n!9+8g&V^<>GiBC$yDtMZkW{%_5Sd`i{W;AC8QQ zi4%Ot`{HvJQmRK};LZzl4?o43M$xJV+}8PdxY6yN6(AoGKfUh8_n$50;BUYMA#CJ3 z9$c}p#pR;S*1V;;Ik&z2*5b^lluYfKI&;ZqZdS35of?l}#L4A37EfGSURBcP&JOPZ z^<_Hl_XNUFN|n}>zPc`I@eh+anvBM{ZV2zWQ1VSk)O?5d%{Lpw?>vA1+;e6jFb{La zxL54~4nq`&(qXA?=jq-f-~aLS<}Wi>W;dBpBt9pK`rKN=p`916xAbkCwI+AI<5r7+ zu8^7UDKDQ_XfiD_xxaN}IJ6D}tg+1cd9zh;=PWpL32QD-i*~2sRq@!Le_4&>J+n;( z-Cx{kqf_!_itN^~w+_59P7ab%+(kCS?(f_%jrBURJn>pu^5aPYi7T>Ch?EG zz{ET>zCH5AqxIrBlSyjvcv`g>Z6nJn!7HWlN>qXtaNN zZ1jB0duzjc^C$K8Uh%c_A=n9ZbviTh;uA*&AK|4(2tnNB!5qX`Cn{L$t5 Y$M9B5geFw5hGkwhMuV!a-Vp*Eu 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame26_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame26_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..a396b6c22fbd8068433842e7fb01856a82de80f5 GIT binary patch literal 12444 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 z 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 Yu`HzRdq#rkP#R~-d-%D z9qydo0JZiBD8!UR7whn|(l2J&8X+kCRFeil=b2$n)Nz2N`?A|cIh{XrR!NZl!`&HD zL!FalQGa0(1H0A$Y_MUlVY*o5^+YQM#L0l^C8OOoOqYrt{&$V3@#|)E*)T1B^+G9Q z(dA>7SPH$W<5bEBFkjjrbm(#W>2U$MADgAOp^ zxQ50@Qpr_`V3JgWec47k-XMrrFE6c@C6fi0+RsxDR4T{%7v|2~MhccNZ|%~_h>s1% zSN$XG+VLNv)H&D(Si&xR-_kg2ICT%RfTMW~9Yst8NhwR0Uv^QR%1nk1)V@BZF06yh zl8N}qgt3EZPp}`o(vJYMe`fJ)Y9Qc?=YX3m;A&(vZvzFHqvnbCJ#cR0ZY_@3?QQd z7>7VJzzLj|C)#_nnppeESbEzzLCz}2>6@ge#ZzlywiEGj|E^suzpegks|EXO8QMC~ zxv;=SI_re3**k5jgjcZ?S9K+0{U|kNKilM$nVd3^WMdoytA=okn*=V$Pcp6~ty+JbG@rfG_eS=kn14^? z{vg_?=pe^m-OwYZ^9lM;YRmX0VabkWw_qHHY#IVCY$+9Crdl1K2JWe8pTDF@-Nl7wSK?50z?=YXl zzx}87Z_+x94d1n}sVS z@KQ*w(fq=H6T?GsB~Mq>Bp-Y2WHO%yFdrS6!T=v?S}#u5ce8eCk3j&hd4T;dgZ=7n z{r26b^68jDYy&GqHKb0v15l#<=t3fDIM@+$VTQq2z&OF!7vjQ1S3*KbBwV7RrJKwY ztRHFB?S`=aoyd;IfN z0IubY$>4pQoC@MiTJaNFl(TJ0TJ1p>HUQ`)c$`A3Azjn146F@J!#^A9oH+oj ztMwuj@lBlQ;ryyO!*p4qyR`(T0|nP|eE@$bR9KspX|-~+g_^XMh-e78Eeq1i6FYRu zmKXedaS<1rT>RzR;$i+LmpwVptUh%=xB;E37p&}?O2Xz1J`*R60*c9yUAC(B;ePEp TUF}?rUQ3vTN}^WR0u6rvVUq!x literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame27_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame27_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..49e0cd21aa320e59b9942def3d2fb51d959dcd03 GIT binary patch literal 12360 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 azav#lEtyq`yV=RxPg>1SQWV?+(U|^x>=lLp literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame28_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame28_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..53faa2941f3014c1d99ce31c45dcf986ff95e649 GIT binary patch literal 12386 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 JL29dTU%(fnFya6J literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame29_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame29_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..1a42f4f87f2fa01b6bc33685dd423b2c126f4a2d GIT binary patch literal 12504 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 Dq@5DM literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame30_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame30_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..c9fde0837a814b3a43bc91527b5e353deea43518 GIT binary patch literal 4261 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 H#T`F@&%JjP literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame30_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame30_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..a6edddd486bf62c9d7d3ed6eea9addb81ccd4897 GIT binary patch literal 13074 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/paletteLookup.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/paletteLookup.bin new file mode 100644 index 0000000000000000000000000000000000000000..a8c6ecc8a5e350f43cb0a1c1dc42750806d8a4a4 GIT binary patch literal 98336 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

Ikc^Zk%tCI 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 PjL#BW-196}EF|!M*xbxH literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame00_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame00_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..cacb706fb76574820a58c58964d02f98e4ed6041 GIT binary patch literal 9964 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame00_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame00_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..8af61cda3ced8b8ce109bf1bbedceb4c05c42c5e GIT binary patch literal 20024 zcmY(qd0bQ1)&{&!NJ4@r2@piEHH3hGRRdvAJ9vPA2~Hv81O;1>88pfu1nsQ}3K%SC zhy=mm78NWB4rno2Z7*om7kg3BSFDOw+AChA796Tkk?e1!@Av)jB_`$v;heqK_^fBG zeP|)mgG7{4MRBSPI#$ih>+Fdn8X2Kfae15LkG@;@`TZ|`d^JAo(GmRclKLUDX!9ID zMZ%0@6t%A;xj##V$L)7sk8i^Gn_^5U6O%V3C3$Dk*26RRllVQ0Fu_@T*IHOiR0p2P zIqwj^|U&~7G0sg(G;$!HRCMl?jA)aFYQt`3r$bGcKBrrrqt z=lNhJlS%VE80yo0t1V6v(V2}mm22h8RMr!mNtqE!2{OC2WZU+qV6|utr=Ht z#>^lhvr>mDg0I|M)Z@9N=U)%7X&^N5es33khw*%0E`d;J+JwV8CLfOqyOX(Y zd7g>Mm9(rWnL)~^_N~Wv(@e3A3A z-#>Wnv5afS8m<=GFe*gRf~LA2RwmYZb|WW*%-Md)LR5)m(sX)#>b4`5s)g$>e7y65Y^Z*{F|gL%pJ|~8yi=LLeo~+QlW^kRHx#n` zi_(EkX+qE6{qmM4>{EigGQt9qL;;AD*YAv;V!3|Tf2&8fFe^OGi$~@Cd}z(pQZ*t6 z5yLMtG&eNIue^N!ezEqyC({uU?0f(FFXIoe1`*h1OtIv*y!JQOvDg(Bi^XT1FB4JJ zszeq#`N2SaZT|@D*@+v(E%VYVS;%5;F<{)aZbb)5FbZ#{xnD-yrz43}TmXoB6mdXI zCByCCQ2mrD<;E9fLv!OB%&q;VSN+rMxc#tp-DhXcy#2Fv^wXKQ^VNM!*KU*~V(Rq? zE-o$$F9f8X5Ew)U_te$iVIHMTXhz=h`j`{J_moBTo=c>8!t^uUi@tf&BPjBh870d2 zz!$U1y>qJ@a_bW1#NNy$JB7hfMbnpTTkk|85cnVS^5u&cd!Nc(u5)%&+i@2!UKwBO zsZMB-#7xOvP$pSYDc|4BLW@yvk6nB8K7KGc3~`R(2d`1ztvm5D)c0<{vq|yh!R&Au zgND$6RlSo7UO=))#6%dse>(P%5WH63}JkPTRC`z+UeKfQ}bM_)`DU0+=7wWhD_xYQ`U8(%cgz1XH)vIZWLKHRj? zOO=xUxS7F={hly3NC$4yj7FhTkSVXpjQqS~qp~;)$^&x4|Cy9uZ^K0%&)e2^`yF}_5o-o}0ZQNek zufgYo)I7N?=H3P6Ck(B&YZ3vmKx3=s9={$`C+my!msvaZ355%ducy6PyeROmA9oKh zW!BG)Q+1NH%W2XRveX^NuQ%p`E0oQC9yX+if1*sN7s}qf+qh9hQ4Ai>6wuqNV`(&% z6?#1@FEvS_>g^f%6<>8i+-0kh_{s&!*qG^GDFVjFWJbEIK|gqD?SCHzKfJIwb?U`Y zOh@m&87ICuP4P_q`2s)R@8;GpPlHq(G4Wo*+L%)%+^@?(cCs6isU9^ZUh=C02^Nd{ zw^H}ciJ2;2wP!t{chAgNlD7HGu=xoX(8I8`_kz~e1xC}+H5WQbN+h{i;ep)gI-34& zLoO*1SS-1Xaa6teMo4IS^Cg+P)aUzKvT%NYZP(O+yeAclkcFZSr%;p|T68$#Mu_Zn ztyLCdT%ecl50}k@2ZJat_j940M~IB+cA=;c7D=(T7C_!oDwX|Y8Rr(_v(V$)cuZr% z1K1YD;jZF}{F2M#@3vJ+t1>^hF?T2_L_20VP;EgvLr`H@VA7m$4+ud2k zyD+-w;IpjI3L1N->q139fes<{9&7s7b=d7C;X!PN-Ex<4GYT{LlEk3an2)YtTbFje zHn`Q)AQgyZ-#kQ|ukcG;ZIH5eWwMRZg^EB#gWpn{nK5hwl7$6GX%+n{RZ42c$yX4Q zQ}ES{y5rZqYoAuBH%{&q2!tL=lv6E{1cb3T+0MQnw<`3Roj=yytru#p3#6X4ZOZ&Q zVV)lGRH>;1gXe|1EtA~lSKrp_z4IX1e%&eEhL+M;=pQ3j?rA(IidVTuXg&NV66A)P zsd`Sygfr)w^lkbJ(uEf!A$*#cgYW}x6cM&6sXX=}rZcknQ5-2^JR{B_ghwfg3NVle z-LtCiFx7~ob#_PqA;341hB=DwC#1zzCYEHFvS{Y~gx5-W2;RV_Z4J%j2@nT)kZgPp zo9Ej2h{JM)AIQ!JkaRw!8b0*D#x<9lYmI?2!-e>_LpKejjag2a2PBjEt70o9TVttg zzTT_MOBH^tRQ=1gCfy&o598O`aqkGyLvSU$Zt)_XMhX$1JR#BVIbCQjQYk5_G;+j# zM?bjkB3o3X1BWK~I zhifuKUY?|rf1RK*wj`Fx;nGpA|JJOWSU(l3a5pDL1o>dfh;CGRoPvk{RtR4M++|^ED{K)Ybt(wY65?elp|V z)+x_XBBi=9=fs-x7xvyX%4ojEKy|<#VZeg5E1J6#yo_k@){%$#R;Gq@(Mzif++yTP zl%MoOFc)$00}6s+xQ@5WsV)8cJ`VT~Gop^Mijht)^C&B(C-F9WWp>^AC_2P$U$&=z z)d&5aF@cBA7yZs{ZTaEy_Ctqh0j-N}2ou7D6tz6@B1N6V*UC%E!--6H<;ZWFKK$d1 z&j`oZcGwl{<0Oxw^y}4mtF3-nWbT$ZRb^jGWRP#Q_@H>oPH}`$T$dxSBCCZ?TSJ?Y zvl}?!Our4BZOJ!OdFBeGL@2GRPt!s!i_6NU^8TbKTk_0SW$Xu8H-ah^F2@hY%t%US zG*J}U+G=XST8qVt=)!Wq(#L*WbhX>{Uzd<)*+be1*}X^V!3#gD@4Z8ui7n0aOG*(V zL)23tXC+rqN~NNj_H+b~cdgvSuvmtD22Sg`51ADd!p6YL#ExcuX>{4nvPykfM6Pg| zM?B3Vho4!PrOPglGK9r*v$%WwBYjRxci3G^o5hkAE8kME8I?bcne-@aX?vghh+^!( zUzd{CkICYMHO~8=_L`eEja;oE@1>SCt1AWE>DmjY1nHXgY-FYr!u^unyh&L$mNyL3>UjE%<4S zv6mH!k%)zGjgDS`n0yLKOsH85 zWOR_xiX_sdB`ic2F(qJ1pw9vn#0NyiyBNz;5?KYbt^6&aWy+kA27at@s<|%O zwCmKL%2}=5>sd+NgE{PJ~a*tBS{vj{CeY7GO8|6xYdJM#7aa) zAy?5tVXfAQK)MSY#OfZ$=Yvd&Kyyq^rb@D93cAYT*bvzvXQ5pZ>Ct+bE25)6Hocqz z6}7iy^71WPlJ}>O8~J&*k+o?d8O2FQCfuHC7G_1=jbCi4%Ew?{5Agf{CW%9R{O;4e z*hZzysb7`y?3vwe3JCtWyGrKKs;u9+^WatVq}5b^?8uLQrSmVX{?{XNv!Ssf@!rFP zy`8~H3f*Avt`_DC--$!X%R0WHuQ^aF{M2}^jsB;#(CEdc8H7&*t?o2g<)a$3Q)E0n zbMAtmq@WB=hEkT9tG2zy2eeDhJ+Ed47&)yJ` zCtT(f;V#;EJjim`0?UL+0>0oG;gyDR>FA<~N72rB{7*oYu zH`i+9*|UejBuc@fXGMu%j~xbs;WvaBi#w+Ho=o(M4U^1avP!rGPG#vi*~((?9L1wA zcl;&Yx_*)FsazhD@M`F0z346H1=_ayqe~eb5&!wPA+=V0uY2*M_@eGZwZ3ytn9>tQ zZZ3IJm9MWV2+8-Nhw#I|WocUj3*$WE!ZWsp2g<{V2!(>8Uh!I6ebNikw<>ofB`K1U zVrU>YlgE*H>jwvkSkZdLuB_?<$s7L~8Lw|?ZGBi@|4?fa9~&gf!kiVH_%KsexMb0J zda4Lbpx0#Flt}BP;9cFif_RpgSHUUbVh6*~{T`2F@WHDNKb#IR0Bf<^fHf|*`ivG^ z%+2N9E+nuWh@d%Pd-1LE(xm-uH%rAF(WFzbK5v^5^Tpj>pZO5KBJKrE~7lu9qeSpfXm zpyCJsaCz>V!8M}D_PMtPw$&;v_-3kzks6aAeJrq!JrFK8Ue3C#QR!Vd|n_$p2kJoe1%KX{PozJ=D9XkFjlI44vP8)yJQDV6oDnO8Qx zN}8bgO%QjeCT(ZpiO90pjdgs6i}%r#b2J8xZ^sr({&wJUw$5(*M`aR}9EgLpXs{b& zZJJ1AXLIm17P_BZ9TWCGJ3Bcmwu~PNN|e!O;ZX*o@*)-CwSXm=F6r&PQ>DJAQda@a zrg1a5QQSmtEgm?e_OMn0YDj{DLkA-F`P&-JyeeT~67Al~(ZLTlB!pNj*`YIh$CmpO zMt!EHL?aMP;VuwYF^rzFh00lA2p7Z3V!!lE^%e{U`ZO0Dlr1rapacGYGZ)wr#iXK-8;nP7HaB=AUW85;R}2 zFRIpN#;21^4h?NVB{D;%jF<3p5E~RH4YNIz7(o+V{|<)tiR2PT@uwID9Vuea;qJK) zmz-Gt@5Dp2Mvn?V-xDs?l}mT*n1Ae3Yx*}xPw0bHv6^JRa1@inujbW-%WCNH7cX9{ z^jzlID9rUQXi+ZE31wu0s-^cz3TZvLdRG=d7oO`w*&(|vG~XJibS>ZjzUz+RaCeHx zHfZ!8h3-7#of_tQysxZw#c|#qQEM6K%>#-m~X-m&PVJVdu%HjX^Pz ztPjQZkk_YqPQ#;AtyFJ_=qB;)dwyWM6Fz@)jS;uNRG@(Y#^cj18`33 zfE>qG1eGjHrYK*GaUHo45l>G`JGrYLa)-^gylxO%$EO#ss!Xe#vaSnk*igp?cV4*~`r;8V3zumq<6~7utXJRE`*rP2z}jgC z!mHD-F#VDQ|8CxuxiyNfnyinnY6>OYodSrhk#(N3>MEln@?=b4r+dd2wj}-F7g?ZO zMo|&&-w8@y2&J`kNQg>G!&w4%Kf&H@AQmALr_>7CSOJrbtZ2N z`M@+fB0k&n;Qh65j*7N`u{EI8E5D6d`~WxY=eZoI+22HK@+gR_vZPS*-tINkmn5`i zv{Sj3beE-^ZIHz%taomq1CoJTR#uaNyI?Kg+~>=xs~b=2xwhSY+eF*QtywY}GR7Op zirl+rtZkMQRo-M%;*|AV?!cTdS5kVfL5KvzB<-s#=V>2)h;*O*HevP)KT&1T(d@MY zoo0_uc|xv-PEodMSI&gncYb&Z4IbvX^5%HoH{qq zGQ5#jBJmF&B!Hqq`S7LrsGQk`T*%+_A{w8aBw%KefhRjVJJ+oEe8Yzye)xrHXec1p z&pFX6h7$q!Oo=th>6z{&w8(N%re98MRnanv2x?0_+tIkK`t9@Qm8ni&2DN)y8bswD z>3*ajd5&keB9>VU+a7~YEDJi9BZmynEkZa2Ei07LG4oNAhq*;?Q3<`*Keg_$p*$~KxLFu#IxPDqUCB%`g_Z&aR^`-g`?r@&g_X<0f-fdDnRw1;>A{TARFFmGZaFh=IT}P$mAqdh2=#IpqiJ zG9wE_O;qs;VhDfFWXYFV2bOVmW?oD_5;{9RGczt;GCGnJ%1a83=ySc!s$Fk3Q)u0Q zHK0kNmKhfb)WXZcx=zBn*r=WnT&2ku($&bWUmoPesc^EC2kzcBou2pNRxg=@i^ zGz*bTpw66tj3?A~H=|4-!xluXU3x|)3Z5C92cd{+)EpAi1@6U~urkkdxx}0#OPUi< z%2`Y04e04*etCY&!#FVjeQG*`@HLI*Tu{yVpqk5<+2+(veO-|I?%ZvR1$??kQg93G z)sAVXjiM4$cuML|_>5R-q|>5>(M}<02uJ;C*RgOH)(|~L;bnJA!OdGUQ#%>w_IxR_ zEg6zbU$R-)pe&DRo{M&gJ0ON!0Sg}5y<2r_;>=?Qu1K=8FRBuE8Fd0~PWE{gq2QFE=Klt8vq0tO- z(IX(idG9P1s0hp?9@S_}OMg@T5K^Yu(x`}65|(8fILv6Sf5A|GKXfJYE!*ta5H7DM zNasMq6T4l18Vqm|h3b(L)Ps(CDXnzf{X>g|Tfz$5bGIbtFjU_0Tz{?(d_1%vq~ym; z-6#JS4K5B{94aT}#L<*xlx&KLTyD(H4p#-w!fkxLT zD%SP9z%MZ1T7IaFg+?dfYrwMgL=`hYEy`FY0vPueOQ?0vX>~O!%%LL0!i8`ltOct< zI&uY<5c3_?+6dO>$5Ty@abo|gSKB$Z(Z4wryI?L&TBwYVyVuG1dfxuxxLi}RPaIS> zgegtdC@f)RejC~lT!J|6LA5}@ak*G~&7PfY(~Q4=^VgWxq)1S8J>t~scBL7clOP~C zFWMXkMig@(AYTy3LN0RV6GQ!|E57Cv;^Sim?jA=$&L<90bYwPBUF?_EW>&Cg|+yZW7V$8i3*`6?l|F)J5`;)gZwnFtYJ@ylEpN*_j&6v%$XZ|IQZJ%xxx0whtsB2^^%o;CM()^2-%1 zeg;<=w3&>;MF;$BShcL^XLBp1#5-i5RXhq6^5}v5-4-2p^jvXz2j435LJZLh0viM>D9uDG=)ecCtZqpJ!_2@shm^h(VXeOdkLzM~K;4w;n`*L@2PXkq*Fvg?4YVSZta|4~>TV5`BUQ zxm;dfpOu%7bif+w)@u?mlDAqAp?m6RM04FJN~edK0j}*8@|Fj(15}~*)$O**@G!-| zn6Vs)e+U{7BeH-#wOoBe>U89>rR&x?+A^79EUDQ{`zfq}3JwIekwg6TDd zg`SRtfW@FX$2Uvx`s;&Fqra+X9B|#SfY9-lXEh*7MUkdfGmi4{adByBZfT(d<`=Rf z6J$m-5M&l_=A{0d_f?!%AEJw(uY2Hj^Izu|KlBmyECxjOSk%uJOI zwEAtY9ud8)0!OGTKnwxjFIlizkQqkTYPs$#^nsx*!6=m}gg!H6utpFNyvxE|Xb1z5 z1WKY3iv@@~M#|+TEv`$l zf{^sKC+EmIEE^G5l5G6Y9e8}A>EChTw35-QatAPs{!)35!_~82l-r_oI zK?#Uo3STx>NK?ND$RZr#J0zJH<~2T!9{?lHfjDz5j!UCuXX{9CX&w(fAX+K(%vet! zh%_oTtA(-?49mYT%D1%lpC2B5cw^R3UjUk%s{w1D+BJ9#W(%;E;!!7MVYRC|0@{Ix*n+iU0&VXZ0}gB{9x}Hb zI*2$|AWARk?KKKpx4G?@ovyxfN$rx55Tj@wbosDblT$o$Yw_#}OkxxpV3$f(ys$8q z>&JEWb`D_13K6GwQFitgolfUE(RC))=H=zK#Mk`Zi*4!tG#*qta}FJ9F#LX~EMfXN z`17=ZOSS8rWV@F;0HhgLE*D@*6@omN|aZ2^_*3c)oX_DTkyp zZTOisil8G)E5)EuO%?{*2avQ95VhIDqcG+-Nk9w#f&k@)o&n}m!WEJr_dx3aKZd)# z0}H{>)x$X7q8?~qcc?LbmXDm=2ykhz)D{xN=7g_eXO^Qhqst5r^``A_b~haPB?V9w z3ne`VZloM>Xpqmh8wD57Z~d;)yH0qyRxG|5KlA1R#<|+FWg9d@7iPj(&z#JHL_i>@ zST9-SAHY$N1PX6ANn>Ky=RepYc$jPCHLwfGs@9m@Z`#{dx@O$`&kHbn!Io!?M z2(AR`1R7#d)F!ImmcLv3RVziMP|?~+$3Yx%fez7WHF!dFxz6y+jl)DrG>CK-OUln6 zh6K_G)&cQV4ic~&e2Eb=-%)o!2fDYXh6Vk&_%yi6pO-hi@SldDnS%;Bm*}XO_5Wq! zN?O3r4Rt2du4I+U%Zoep`^_^>?>)~*>S&HVFNAp9#uOn}RJwFvckN+kv>Amnm7ZV< zTSj_$JSy2?;l((tc;Js|Fd)@UYtv2WaDo{ZEp~oMJVWDpdsbp%jEAE&Wp>~Ls{hqA z7)KiLY`$k*Ob%EFJb_v>GC^~8>;gn43`=24EWFHaj(B%BULe8*k4kw4ohChst2DLh z%F1&$FWVx;u6PH!j^uS9$Kp%ry}jO!Ns-1yEdMA;G^NNwoL%s^cVY30PWJcv&K~#O zmFyJnE@26gp&Ox+oN!H0u`V?BVT|_qH}BrQ{fjHEdpUB|4*t{*qy=_^-r%cv9%Q?5 zt&MCpAA;LlhasW=v>^Wl43H@88A1~ra~7kzo@e+K-e41l?u6A3FxA2s(21pmQ;axQ zU3+U}dnK}lmhjbX)--1hnD{mHd?F&6NrdKJD!w}Ut07F6IVDS4YR6taUS8j}1_V5e zX`O1>uX$2BYe?;2uhZaurJ5v7Pzfh9w)fn_ZWUy}m9QeOcenl;&xiEnVDrS^iHmnoGg)5nxckLtA4X3x5Yr1*Pn+h`&8mkT z{p;V0?CoJS(NAN-adZm&ARIeqNDKk*ddyM#E&9&Z<5o{xnNBIc; zdtI+kK`cNnY=83*N8zP3CXZ@17N8}olB`VY*ggmx5A`O?j7DAUx zkBxzm+haG}@`?LLKUw#Y0g7e>GYs_iFd<@@08xnB3v0CD=E290?Qfp{2CE!%aHbrt zSlkEwBn@3fAOiZsT283L{<|k$(7Dt^vI2G~St5lSUN1A;|Duo1JHbKlZpV!n?XXxN0%F^e2#?JP*u&`=Y zFaUKodINXO!7OM_;L$dENF!~b#|gik=h-bkvojNdZP{PiBugM=5EY1?_SBB&p;39f z7gUQ)CW?{hMIat1fPzwy>VHR3mX*}8FDS~D%_i(v*CSU_Cb5`%r+1{awM94L3ZRj< zy1m`f_PxI6=~Eue9sxMffKNyuMH1qu7H|TBDh~`@A3enmd_5|>2Dn1D=jP?TeG8pd zFFoczefqD=b#E$)nV?rdl@D}eH)1M~6Q9NdUpxz8VI-136SnfG5v=d&dH+(Y1;0Di z)iuA@_1(L7%{6+9S%iBJ9Xq!F-8=jE)2kYr*fx7Y-WSkkka`Gm3q{5MkH$;y^nb?k?oKn z#QiQXuld*9+_o%y=4Ky#kF{O!Bbay=Rd1)x2MEv4gH`{=^zzC@f@dNm$Edzzw6Pau<}SPb>8!2b#t%u-ssRMbB{Z*Fcj z^!MMo)jKkGUVO}w$1U3pxmjLRm7*bv-Zg(3;wnw87x3(e+@~B4OdvBsE_@KUk2$0| z*!^^U;-2Q+X)dPU<6#QyI$?9d-EPwef1TeTdf)wO8n(^HZe4D8WY^0#`2Vz}WGgpI z3d2j&W;$>MftK)@K7t4agW=OVk`D|3rePKsF{p(ip@D`U*s+@;0Wd9oJEV2wlGl$N ztjE)5n6g-0;Dtnhd9hab6G8K79>Y(XN2U1x4AG^0vwuSPdp9^W(NoFPRP1Rs=a=H6#1XhMK1yt#{}ZHTJ3K? zb=e)Li7VxXa{bbhxojiA2-J+88%Q}N>!vS05KuFAzBx@16bw}OIIz3zAa!{CV0r(( z-NFS}=i5}PV?i6hVhJ&Oh_1dbcC0(9)nXCcdTk%a*Gl{$xEn0_J`4hVWja}sc_|l8 zB3KSsc5|NT!S@zHcc)tiQ+I)DId}BI=HQT<C!mmA(Tw~b-iZj3{gLmUM`x6xJtx8#fCRAZjZahcUWxy%x}R0TgpiPsb4LbUW-(qq%(txv8)C;lhXsyfh^llYF#Mp^ zZq_roh+T0$xK?b#z=|MZ5Q!e4Wq1A}kNLKD-D_Y$3iV}I`pS$Jb8CPlB)++_9#Brc z50d}{@M;WytK~sLA^QVfJ@0w{9O47AIWT{?2+Bu^uC4Fd>He(>rg)-;>;d&Y6LOM1mg| zPuWL^Gpd;tkR=c=`TfnC6kZ82&7yAj239y$?AXCd{29zgbR&B=NclA^o`u457f|IA zA|~$q<$I^qvfl;JVTYQKo5P9=-v%Vx3HNmf>tTKoW?3EO%Wn`ChnQ;FUZIodgWYI0 z9(BYQ+AY|OXpc-F5O`;XCxc#CAw1i737PW@i;7`GX-FitK`w>Rf>Plx25(wr?zvz$ z>}7!e2O=HyfF*>97b0}f6TX8St(&&$MdT!5x31lS?7XkAgG$c6LT9H+}I13(a$)Lu%-+d_VSNNQa!9 z4s4(3)xU-yoSB{+<f{T@N!CqiF~|&U%G07fu#Ql**EBI_EHM#cT05?D`g2Lj|Pl zq0C)De1{~4bDLpg5Bc#YnkY&_XN|7FOF=k&M0MTfxD64RKqFb80v!bqM0MU=o-l(A zH8J!H(O~@DImYbJnJFiPIx;O6s^rTeg`fk$gz_Q%7on36kBn-ttL{$IMDcH<;9t+t zGh}CbA5Ykwc?;x(wW`T&&gkT8+Y@_qdTV~QsWb>aJz z*rr2FWHOL|K2w&hAQi$mBk{d;;~zUYPDuV{nPV{VCCp)EH84e-sFnB+3kT_i08uX@ zQ-bM`dT8j$TN;f`O)p0=l)R6LZ#o(~+Yz&X|RLTO{Bdx}B1F+8`WcuTQ!I#1yl zD!vL87Iwy?8+ee6#*wbvduPC0qdf|UhQHWM^?m))6f`Ho>)JQZsiEBfQ<(X+8qM8;1;=R)nbQdTDmoHCDq^SI8 z7V2(Y<$-NU?hc0IUH%tZ@QjO1^>y(dAI^gQ$;M6E$pq>>zo@5b&F;pDms|d}u^&1* zIuLD=i`yq34UWHQX?6lTgIdkz+UI6~+S2iygx%1D7M;ss2o{#4?!_QM*#Z7uMUgu< zulj(5B%Iu*(*$19kQA8&)x$Cx zVmFBrZtJV3Ox8t+M~)_Rp!MB>Y2jC^(&r{f@Tj=>YOe+UH~E%x;E92T**WB0$^q!B3uqv=|KJfie853DVEejE`TZOdHlrdcl=b4y3CObm7Iw_N~)Kor{PwJiKWo0lBQ@MYUkn z?bEdyI2mOsad17b{aIccYWK0SfBAaaY=U(1?B?mZyMLNDY_T+VbeySDWgPh>@-dEj z8T)$Y!Fl*P;1saMQ}aQJ<`3=~YVWv&thKm@6|Kn|sNjZ&P)?eL>%m9#Q6;`-D?K_d zgy$TH+v8HhU7kYmMtU6;A9@LMh?5ZO6J2G#j(+^^rIWW0%-l04aFJ7)D?y)%d{U<5 zZ9^qD<63XVY_ZMQ)a;*6upVZ^{yL4X*MXDHp>+54}ge`eD-dBBuwD~PF~esVH8YMr=Eht z3KmNjg$fAN0!6}Y?0VAh)1@^OPZfU`&Rj(b0FZfH3!FfRL1OQy)U%K`k7Lk5BZCjqI9=}mi($>QjZ16$zNF0p7r*~Nzj}54Zlud*G!->@G*viFcgkd$ z3gi5DwRSFG@^WDy+EaU~zfMkOnJ77m2J}_viberfeUm9;RU6y#hcEgB6zUs3QqK(v zA%e|Kz#PT-9_OWYu%~GO0Rg8Z?|X#_CnsmfR7lEp+5 z-`>ya_C_(y!cn;P(xs#>&4%c7CgcR2&%sc4fgjdNHw*WLHwgr~24%)%Zn$@o*RB8* zvd1&Wk81_tessZ#n}j=mcUSwK{ae<5Gvc&VT?_XWZ>X~ zCrqkW4ZNO4UP@sgG6Sf8)vk@^UAI3$1HIobQ`+&mfq|Nt_in0xXDS4|A~PJ7adVsY zF#!QtN(&~>ACY=WNab2^t+pvscIsI5V=o_!p4A*%d^`F`f2b|kY_{kO3d_~(HHRK$ z1Fis`S$n(n-}(B5oX)SqT`FUZQGzsqUzI*XAD!hK27c0lbg3ySFMY`zuUyS~S{N@j z9fnr~?i?P)BRmfgNS?=f#?qw#eWKcMtAV1vNO8<^L&RaV>35K?Y#!aDhvd+2D*KH&h<3b4uaQ+GEfR4hh1IGDE< z&QYmqmD1LmvMha_o>rD2#Y>e7j@KttV0Py!js3{`OU5c9J#YEKF)DI-z@v=XcpE?nIuAxt0*R!2l>Y!wC1)}O_n}UqHd#}eto}kl1&6)@`m5R9ZQh<77(=~r*fYa_I`lI@*KT-r z>goL+pI+DGG&g7F63F+vXy4B0z-?@IKEYToqwR7}U7{;>4~=ja#3qPb^tY@Ywf&>x zp8@?r*lb<1F&;8}aKmrv0lyP7PZfS`bpnWVDR;-KWjlI%9T<2ra7@b*Jz*?U=dB|M zLacFt)_|iX^HlB@NTrr~0v*lHiOeo0gXB?ZlhdZ$IV3?r9UcM>Or@sM(aZs>Uvg2^ zqXL$L4Mo!cuz66<%D^P==)E0B?)A>RcW6z*4WztcPG}R3JyH?E1!i}l5cLHLat3-# zX!Ytn(gRJu-ZIK!g#UZD6hLEE#}zPvrrUtmEc^e&#d!GCWZbQ7x^Vep>8ww_qIZ4z zO4~mtt00E68*wZ2F_nCAso#GeJ$WdMn-{s-ZM|}pc#c>UHkSX$qVG9%^iO=O{o5|* zwVw8OcD!jmQ3#O-&IvM4BxhE$IOEWvmMA5~0bVRF&^1cjwDQ)Kqt{96j{K7e@Jq_k zA$u%I5PRq(*`<7 zkTB0B(R@MH;2HsdeH60)L*UzHRF|w`9guiLS4;L+2~42{T(`@^L8vc&=Izo|Srr*1 zjI&3*bnkk-XWh{s=4=fw6*L^+1ki{RTCc|_b;d?tr=j1E#T*R>f>Pf4{i7;0Mu8w?Bn3#RQZQ6O&2OEa;`A)S(&GjwuY1t zGW_miVOW4(*Nfi$(m(#vQFG4!2AClmR1eQ!CL$-A6dGc6$~!Ja)jj7_>m%9TK$%ZK z%iSJlt6uE3`@ekP_#2F;4OsJBMvMs6Bp4-*S+K|f4&uu`zJUy#{_26$4F9g8z*U+w zN*5~^99e92Z;~Fpe{1H9JZox$(DYH2N_yeKqB@_;iBr#Er)g--McTud69IpmY`Yo% z(Vev(9JFqs&lE0qONI2tN1YBX8o&Zo7oXQuwWda|(Ew{M9_e)n3CU*z7rX9O`jKB0 zn_G;BXssTnIPNa9*DO$FMn~c)*lk)D#u1(zu0AIy%)QBMdoIFwCxqcw zo@?%1Qon#~>WZ!br;-RFaj>dT9pS;TXLg+dO+eF8912Irk&c;S8G)MV+279&bPwit z|AvVFzxS68pzQd@9Oqo!9%0fs@Fk%L8(tOWXZNie#ai{iw$Gd2xKr}jczt#<;&3%o ze2_SI;o9wM2A=!`C$*uH*bB!EVuJgYjxU?MWu_c03^dYq(;rHWrN6iRsY1PUnAgaw z-rX79R5u?`%Sq+xld9P--hc9mz%R^S9L8bSc~=Jl%MKP{DChSIeoRC?ox2JJtHf zoVcg$CTEvWF<^~x)NRu+0_IA;+S|uPq$lCv2xU2-;{}GWYBOa!!)5HYlSMO50m5_n z`StuAOPBiW5A_g-u2`{R+264o>PRP43CP9Lx}qDyk)7VFs$28inxM{rFpMNH*iCMZ zs|Ou(7aE1j6oHQYXL^YX#GA#uh`R6!MTV&OS@-#IS~_jkAW=8@EBRI(%fX~f!lO0l z%A@>niI3yt%$vUSzSqNj@@tD1wLoOo&$c-$ie3G7aNEBWPYv5W8ke+eCNliZ!9XnN z(rV|!6br`Aj?-|kXW#EaQztt+OPxztwS9=I18TYL$&=wWb9*~Tz{#Om0`1@b+izQ( zoF@1j=dihxL%v=6Ta{Xn-P4TF_4q?Dg}5kFai8g!gt);pz`HE49QKW5K@(xGktS*D zbf?MwYreMTpS5xk)7jZ6B!AfQ;i=A_+G*|F!d+skz4~S2nwMgL?|9fyzqoQ%uPNM$b zfHO+de67n_?TZ`tM50cM{5Lv2xDM19<9gQaVQ>y13WPNr9Hpd$7Sgdu7iU4 z8h!w4CjLIZ?WlX_V`3Uw35BHA!Tj*jeNV0V!;0rSe6B^Zu=BzUF5z8W2jq0ynKPC} zaE_gvRQ+Lj;;u3+SZUJS`q;VkBe+#DD?lNDt}HDweRgCtTUr=9Pg96sQYL~ z)20L9;ns6aoGN}4zvi>E7$-cg8it=njwo~ zK@0pkA>_{R@RP@(_&mq;;^4%@$s6jN3+Fv)g#e&Ra`1`}104FhCg%1&{p+7+eV+u> z9{8we|9sydo@d47xAy12PyD^wZuEZ8%?^J7a)!8gq^;e8qrg5q2z2wt%zcAw0JOR( zY@;C9I0LFN2|w3wncs#mr{JCQns&Z^{rW|gGCu1DjBV+3gg>>gG-dfVK&&p!Trlu7 zgx;U!5Ma_4+Io+Q&`MG1>~-4tOoP+#xlzYFkuWCAZ)Q>Pp1TJ=xP9&3KL@3O4`UkH z<$e0YEcAmUvLzHq_P_1mYK>kSH8OHaE8NGno?C3#99XG#1Kos`S_@Ez;+*3mf9nBXV6uIw!<=vn{D$`^y=30z-a_gyT;N zq?3pP=O#un%0Pl3asM9S_tVdK$wx-=fpgZYRwdJoNtKA^xYHII42>wVO6X#`1!L!RH^QRAhR#yA5pzu1(I35R{7g zf>U=0RxH<|+qc)H_irhzC$`TLkO@$0{M_+=44S`a%rx zjLX@o;wUjiHHuAH#@4dKLO~mr&ojp5D!g28S*zI`*BC3IQjHCmoK+f~(e7fij^d(OH?0sJV8!T$phx`#YMr~y_UR6HTQ=kxi>`jqUt%5F=? ze{ug_O1uZ5Q=$V)R(LP^785D5mh5j+@Exno&_>0rx2k$Yt2cm9(+xr{8+hpXx&!b9 zvRELXW|D+-I)g+$N<)7Jy-xG8Kc`jA4#AckD!B7@B1wY9Y%nr`0xWJpczd^v=WqxM~>XKR4{W8%sQeVeAPrSHU% z+Fgb9$+7a(Om0P9+}rcY*YAGv5U<{UCUWb0XVm8|7o1A{(svSn-}Y41s0z|&QaXDj zJMs=UW#@F1p{BCmb}W;7R$Nl!OEt2zNSf^G#$5ci-n5dm0W6f|lDWz1^(FqIf>E<^ zTHF?6CtlG~RlhGLURV?bgH_p38%IkJ+TSc)r%v`IySNsrm+6&2>;UFlpRG3-WHOj^ zz+o@|gIWW11iDW2Y!&oO?{Lvx^Z@%5wL$+>4!tOX3j*>M z*I(;aMUNn(UR{KD#I(c&o`;16EJIf~Q^I`_gSgLLAUePCS7&Di$!o4^ZfY;@%wC@; z;sO~%+CT;>nvnhoxFWT-KM2i@d6B|9D1D<`$MnU_#6NNZCl((-Ov(vgQerslDeGbo z*hP1RqXZ=E?a`dDLq-P#u~BRU(eE&4cLh7Kt^4>fqy;9$HZalPee}W=*eT zga2%NhX=24TS0_`)1eb%5k)Bz2C`Om1NIT&_CA8kZ(jSq+$*uQiaQLhpo}(jIvnLbo)O6k*GH6Rne)%ZmY7c?) zqN4c`uph`=U0U5#8KjgoP^MI@q1c0+;>LG>WwY6o`^x1HW|alAL~(BFGTb7P2E==( ciTS@~u5&nSn6_iVe|)$Sbpku=QXw$;|5)+X7ytkO literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame01_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame01_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..7f89acb8f860d88413c9ded455550ab35236186d GIT binary patch literal 5511 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 J2Eq7`kG?DC+Qa|= literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame01_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame01_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..620c932edbbb4262717dc690ca12909601c74265 GIT binary patch literal 13230 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame02_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame02_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..d5f25ead25ce040e9d80f64ca860a30f53544d7b GIT binary patch literal 11606 zcmXAv2UL>p7sqp!D|2KTnOg2qZY(QH)7<7FMa_v5Ff~V+BX=t0-sZ-Urh|I6WJ8&(@I>i-TA3oVQN5E_#SKleb|tAzLhZ zoD$Z)*0z<9{K|wa+w8G%S)|D4)#38#IKshKH5!2S;6yut1B-}6V1(>S98Mi0qR|pq zFN_G*7yGghJ-p`?Wj=6A&*`O@2%$YiXpjxex6tl?3;+k7eqi|5r!h!W(xP3;&0B*` z>!SnRfW5tOwhIf(1^QNv`rOUW&I80QVh^$Z7T$kJ6X%T?t-1{VR`SAF7Q<~JHih4i zTH|AHvru?dqf#vTw?1OecxL<>ce<>%ELPbkBM<;i#8zakKyQyD$G0q+I<#^u&ej1{ zS8-NW+16NrJ&#<147NZ9=x`FVXYUClgzf+f!1mukoTlV!;igr28FPMJt7Iat)TK?7 z^O!C13g|W@!Or$|Fr7#t#vs=q7m=GzUT{3Wh{~De;QRpl3TV%G0JE!BRim2FhK`-IQv zIkY|QQ?nK%N%^JnOI)C$fCC)vr7itwCbvZS$0C&+f?WZZGB?GC1^yI@Gpt;>22*jgiQ)lzm=?^3>WTCS@T&~(S+Po?AL?#bm ziO56cRps@mFb#DCQO-O;U{9Yyt8VTyYr2`o({Vi=bfq1c8sixg9g{Vx@?@`P&|`?) z76`h|jVzP&{9uKn$$=`4saSn9X*K@2a2mZpIx7&dRmV}sO_I&N-2GNT&3CA$H<1@4 z$dGt*ulnc?kDi?FpLvlsis~`pnEV^=x7*B0=c;u7{i<(?!vHS)&1>(F{B)5787t)n zA}Ujt?cT@B2HbB93eQ%KP>#TGNUH`dxnekHni0$hmWx7la&8_%1|BckqGz?l=ju|m zegu_C3WQ{D_t|;5PToI|oMGO3)ENTM9`>^R`ZhK-wz?BwI3;>t)MYbjGln$=Rmd7M zB&YPz_UO)IXVqO^2mI~=88AL30H<|YdLN%y)@74YW`#R>`pUgSDB@0c>7lml0p@)- z>?5sc5)pLDV7o!Pi~LZNeWA-ML{sj;TA z!w%*?F0PFN1n$%v|D2zdot5vuGOO4x6fF|vvMJzU@X!tcLR?V}14hR~#G=No-DAO@ zh%L#xcrFuYwt;-Va?SJ?+_;AWSeL;|lOMs8}tt!HZ$n>^DLY}Ii(0*ca(2=(6h@{;>VO@Qy zD~&CMZl@!pE6vf3MXE?@+3mu326AY$M-mnZi$MspakNc_+8+JAd7{MbFPEa*t{3ta zIGmsG-@364$0b#%><{8-dj?^c@i4{-XR8m%KztlTO2iJ%jjLMwma$( z2d>lJJKqL;K>AVY6)K|pgZAZ2&1|Ofrt*REMQ*wMf4Gj$8zskO*8ma1@#WT?>P~%TD2<dr%bc(Ax(4+Xw! zlZhogCf2F9*9ePl&_Z0W?qkXB*Of_NhW`9?(Xe1D82+;J3PKC<2%*jPQH0XN@MofO zvTU(l82q|b9QRE5*`kz(RalUR>>w&LHaY!bH|Xlq1Sf<&`Uy zE5qd@KOO9vvqc?mspPPJgMZunuK&Ga^dd)#?whDCOP=SZxvDV-UUFnyDYgPzy<(L$ zKh<_8%z1 z3K}}wpyE`3XqV}wut<`$vmM z!vPMXa9T5`jClk7_%nZ1(AX{yI4J}SZRz(zYcEPzzF>dW&j z>#N-9JRA;M98wi7pEX}_6^6!mMun|&A`oEBA9*;uGCs0?;ZwS5@CA{Y{cusxrfZ{j zi~HT<`jOcK%T@ZxE`EqjsD{3-#9xYgstfA*Ko>^IETa#== zW)9r>5xxjNS5?=Cu8&-G(5(gHg}~SM#_leq(hs&x&Z@fVL*qMRM1B<3i#C4>I`qw~ zC)Y35uSQ&~U#s7$-?fkP*lgz2=Q6NobcG>;lP=uvOui+<_RD7W3hmt3<}8JDX6ul1 zP5RI-mGPLy2l@_mDO9o{To|K~ye34?#{M-7cPC=&0{vfYZIt}i*KWTb^3wx-FPVh4 zJT4y0x#5@!IiP+q&NIq0kCy8m;y%URBe9askBfGu8w5W5z$~E&tTkQ;_zvSi2cEar z)?;wj(4^A@X)3_)+sal4b8F3{&7fobVtZo0&ER79;}~apW~KusmJPOeJWL*(P2xM> znNv_w%6e;m@U-yT#9Krjk0{GVYV?tEqHdVkSWa3TdS*=s+%BkOugmkUo9UhsOd=+J zrS9pN5s+}ID7QLXyM>{>S7)zikL#;*AY8uv8ON^`kB;x*hk8t*O`ozs3S-?M}oiRWWPQcx5RHMXwR#%p}1rW$YGG>TJ64CR8absdVtriI&8OYAwI0lZ7sDqSmU)jP>Z*9u^5N{Kj(36?}VAv8j zFk`G<%^NNjj}_Ox5kzFhjQ(&-G!rp_qH@Y{4P~nGo-Ck2 zlwu$uvJv@j$ha>!B5uyVOHO%CaU3^SH)GB4_?MKimBDTkgTyPFHV|^vn-oxQ;9hir z#n8jh2l2yXCUouEWbtHUoOv_i*Si6?Sl7ueQt)ISDU=iz{mdpRw=}o3>iO~oCHkc+ zbq95amoX_lRl@RD+VyIr6%Q-p!Cfo;EJlT{W&s!*(obo7i%$;BmJdGM2zAwkKn4+? z*i4NYKMCJSAhDn)zf|fvXM|gX*LWx(LCEXp3bPz!l$WZ^gRf|o8mPsYXN6`&2$!9j zK}hkWx1U+-ZOSz9f=X2yG8!9Zvv0EEF2~QJ*jBnkKR>UxhYYEp8f6*{8WD{s?JXU) z#ws0|#^J_Qsjzw&{z18clYJZ0pXmHt_hgeYL3aqW{+94xnsnZpNU}RrY(k)$m3HT= z#iyWno|1iZTB*Skhvvt+5a}BC0ON=i+>b<^e%vL4l6y;vOZm7p8LMFWM!W&uxPqxd z0&kAD#M^ROf4M@c`EvD|4|3om#(cJ95V%x=cf-TqbMKCC?Rs<=LK{3q(zlvXW({#q zto)%LmKiM&&BM)GEo>GB7D=1D-;!iL2rlp|M;ylbRicGzzQ`C!-s(4?JF~<3mBoTL znx{s|Rmrv9t#Yut_VtUw%k{j`*#YKJpI;~Tts;+?&7^Yk2CYU z@P}+x(T|N%p3-$ld4ir?_q;a6Q!P6GWU<%%H{zT7o~Z#oH|A}uOu#JDSKYW%uks&d zfltKkJnc)+@8*wg4n(}S09Nd;};LTK*7*i`XCPk;xFPDae_EabKGT1t?H;6C@+*) z`-Px5$Uu8!@4J?YitE$0oLyAARkat>_>n*6Qv)evcEw-=ZBnTv&+l<Ihk^MbdiUJlZXB)A7Mk7d!?z zEj`3DwgNSn@HF*5SykrFO^;1a%&{0-JyUF^0lxBPzy4CnvDYBfAkrYYZ20p-jsnbM z-2t5L_Z8Q+PmpBo@}B-?l+WkOv6h!ca3)M$&o{aD!7@7>ME_@gM%pKf6VB_P3fb~d zZ(!?axOAOnE5a1}(K$pV0J-+P!*A}&+|@b3x$8VzHK`3c4F)v@9(vD;o_(;-w$F!u z@yMgj*T6pfQB`yi7np@{yuo@sB`E{R3s*<-AunGrYdoUkrQKy$OdCdm;1A(iI1AS@ zU2#}9n(0~I`>xQLw=+r1ns={X?3qcKQ|SsX-h9}zIMsz)Tq-oBX?SH4wRBE^fn2feIVO z@`mzzF+NnUZenhjjheR=E)fY{Hr<4yosPWC6W}!O_kRwFy@xcf(p9_e%JJ_Kwn=;- zzSL%BlTrjq4;mgi_vDh}*Lywgsl%vE?N8E)&}G}R)^^PssD@fU<)jI>Nu*4HXfNHK zd09UTbSlOM$weL1IQ1McE_Sq(>m5>@j^BX=wc}g+!6912qyPL-t{8uPpRwb=@RK*0 z#7~MtO-98=plTc1UVpXd2!=Dj0~{W6z`#m9^)*gW%Z$C{yrxRETMgZ{2RddKtEv)Y z5~UzPxJCzz8|Z7@5R1Tqk241P!l-w zrz;S@WImjTNc`LcMj6zVa2hv^n^}VdXiv&x-BdY?ygoQ3dttqror=6Voj!OCdQCcE zojScpUdvt^oE7I^`SmA!1q*-$zPQ`=Jb~xH-+%l`C+iZo)}+e-+8(-JJTcPwPV^4$P7F?rlGpc!mt0X3#OIg2 z1qHkvqQbNT?P-~3(AF_gx7;TM?-ambL|RZXAFbC@mmW9hN*}L$<3;GuT-IEUFok(Q zS+wPn_fmyQoS+An9}8EY_seOekF*+?vzT)<^3hHl9U%vV{|JFmfbf+-dwW{*W7Pns zNuOk&wC_yQdqnw}!P>I;y6(5vQZ7`qEM`>N&2*y;671g$+p};L2@b^fe>cty0mmf# zvU!l~xA9wkWz0~Mh5^oTp?1sz_Ssy7-1yLt=K*e2^g(y*ObJ>_N^rwFTkyBwu@G%E zPqUuvpDuspS1HZP&Z}DLnjbeK2FM@oS|bJ;?l-CtyGJ~CQ9Hn48*nKM;J*t={|)TK zAtBJ81^p6E;?Uo(19D}VLj99(QzyqK4+Qx--#F47Cf@I}=-Tf3*$3_Vb#0J|6RBN( zr|S7vtxT;lS7{_cflYa3OkMmO8`N7|X+V7Pq1mI2?5%{xMw>?aMpy#j0%z4rCpl<= zuu!{`JblAxyI=9mbUS%I%w@CRx_+zZRorTi2CUla`yn+JM2f?m7@Ys^jqF;Q4*`}Z}ymdzd7 z-e8VaO&Y^&uD_i;oSH?PvP=`Q*-4T{T%l&_wxwQRA8K0tTVRL;JDFP1&h^ao%?Al$pr`%A9JieJB%TFTMnkmkt#Y z73;1FOSfl;gkPoext}Y1H_`i;ua>{fk+J7ozL2{^+=cFlaz_R3XJRjS4f^lg{QhHH z$CokZ4RQLL6P>q{d){?OPAr>uk%7v^k49YHEX7Z%P`4+BeWQH21l0WNxZ&tZzan{4D?NiovQ#B-{y( zL|C!*&GpT9OMbHEmh?^Q#HhTHyL$*X*?eovCgzag3TN&ZKzSF7319c+-5(0KVwCaut*D^P*rci z{ne2rX4;R(XIYqjMoIBy3Vj~lG^_71?LH|(jhi;Hn!yw4Lfi~TTIjsB3j@tjR91;c zKM0ABZ4yHbf5~bZP5LzZbhQ3)C`l4&TuqWlk~U89?gz!UzJNoM^+h%X>IN{6YRycT zA~@i>GwnIP3A$=;8Ze@{k8|X;V@3OgxgrzcN${jt^&Fn!S(y4j(Pjzh{gm_~jO$ci;YqGG}`zM#Mr{U|4XF|+Ge z)VzuUjf^yAdh7g({(reCKKfA_Q+A`Tcf}kU!O!$OCH8jzZC~T);RERE$*s!d!&+a_ z&&nMmJm#Y1Q%?EC7osXtq(tcT9I(a|9i9jB#`4Cs(D-je?SB1kcumX0S}se8DYJ9# z{l1MoTQ=Hc`FXKw+WjU87^Iz0n?T2(f@Am7r z=!f=OBlxXh=4|7p*orq%{ZIStuUoMHhkS-)k|%Qh;*}L~S2Ar%aNN^$K)t+0^nE4M1YdKnxKj`O{j!dvQ4Y(JTDZU>%%PQwS5K<&nK6rS5-N{^Q=XC6 z(TPmOby$J4Mo*Ibr1qpAR$5wL`I)-^L&P~ROcxoBjBu)V!f_8<6&R$**Cg5-$WpXg z>BTz6O4P$vXN&;8q`jo0IEJLtWZDnL+7h-zSxecp5AyaH2YH7oe;k^wXFp|YXfU}i zzoc7+Jm|Te=m-PQdqzK7KN}7WiZf33O7_!{2}eH83-M}|tAti&PjwW* z8ZE)AI{JtnU=oCcD$75U?su4RT0C!ELR85vtXv2s^Yr(oNP0T0IuH{pm`)PVVL|cq z&CT6hVQHII`Z>enan{6JQ%$+%O-jiH8WAS53G528Pf>NHF1DHJq8k0o(?XVJ_Qc!n z2(X4Gsi=k@IB8UWzXCkCgL+B*IIYhl_nyihwx&Dp2^1ec!ydya!*4QEhI>fj9pR)I z`C^0VbyqphBNMAMRN88)pb^1fnrTlC*MUrM+TDDw=k(CuZnLaycm=(7X_2tp1yLRR zXh7en)-LL_xzqK@R05)91Tk`66J1j_@>|m~idushCH(NNP~d+-VpgqfEm1YdJJZeV z`Z%ff!(+>PVIA)E(HFE)aW`R20*3i#`z zHnjmcJwoF>8U-=r&N*{4Ef^h)9+?hfKxQB_VT`b|1`Js}PQ={YSqtRpqu(x3xlffJ zM~uOkkXZ%Sidg+w_!(i$mD@w~w9eLf57g41kNLlk*_wn~&#)Fa2M3DVUlhyRzwr{y zr(w~=^<1cT-Bso-x)J_C{3Q`$%y^?DoAydDI$Rv(m1y$f`1#ixctheI=JOW^TV8T+ zd&pqUE&pr4x2D8s~g?3A56t7h>;rZcdSzE__Oq!QMg&_<-hv6zKkd`P`^Rj@_;gkk-R*PqBJ!9*SYPy_jmZZMYu{`4;VlI$+QU&WcW&?jd^KU!?Q zyz-JyT~leM&)$^h+*?m(&@;=e&(crZa{-_>5R?4ordQMOYslJ1dm+0Yf2L)!Ic*FW zP0~#AZ4%!yO~|diMYpC@YHldhsm65w8h{Si+~8d$y96`D?Yf*Shw9$TR8slmJ@7PG zfAl*N>~|1Exp&1s$cZ3(6`uK?rcsf@=N=uIU`fR3y_S-Qx_;?%0M3-hRML?^zwg66Rjsmw*cH=0SFqNrinD+@sq^2T&Pk?&yzdk`w5EQuaC4X5l?uy_*g5;fo zo3~^SB0RNBcP%S4;@)Gnno{E#Gj=*|YpFTXA<-~(WGPI;kskICS%xf!Xe--Q<$j0(VK0 z*}ST}8oYXvCDSE0za_!X2Z(#xmR2snCebR> zifGOK#{z=Q=MFP7QssSd$2cdIXXv~nC+syWjQe7|EmPY0^am{!U(z^r z-L;YJbS}WwO`?6KJ*fsVDc^-~#|efasEqAz?vS$Y;!$Ou6H=Jn9@Zl+m}9=yJFD_7gVZVhgOGWYRCP_^|E$ z2WJ$pUA-Iv&6|ALqg%fDQ6kGx)bc9@SKh4ItjDpt5N=>d$(9j(QD`r{3$9YoulrfM1bl^ zI1uic$jCzEh3*@{EEEdYZ(Ab5MbI++t2}8^M*PV7oDC7lxSwL9ik2@E zpzVbavk~%=9fj84A$})-?Jz7ctmBl z=^6WdYw?$BdcdcjJyug}Z(e=k5&6v*)Or14eQ3d{+~Nx>D7k^$G#E>6flqsQynZ?3 zZ7EsanF?WwwT{^VzBL20M&*&|Lh;>*K)$4!iFb|)j*6Clg?w&2CVv4?TQ{A7yN8#K zFICaO2G7^uK<$&;&`j){Jt43mHLWqzp+@w?;QPV+U@_n{OL>%f5H)nn*X&Q{&peCW zBQ<0<5T9E$@;%QO0X15TxcL&FFK(3F1foCIc>ZjWjrNE5!9Jta!lI$f**!LpZRc%Y z9Q&q=oAynvIwIR0+B{gmp-58yGm(%%f3mHre}I^qR6QV^ z+?cBjF)}aVsZ1jA*p%cy+avmQr#s$}6y1)tj>c?*pW}VOz7y-Ii&HSN<6S0y*gR|@ zZ{gl31+^a>gyH!jmq*_OsUoAuRpepv);!IP)AY#Z?(`UNd<$Y;zXdf9Z>ekg^)lULK$chI}d1ly`I8m})yVe6vS^bTL1O86-<;1KhJZd2nDq2z66@GJQP z%%NO9{uMq59E1;+Kg1l-H}BitIz1pv`nmvl$2SY#+N6I;C#I9r7t>eMe~2vX?|kVr zSQuW|`pVzJfI6p-v&H=swe7JTt1h|8ZXbdcU>Zg@Zm3BS4*?P6!Df%^*NQ&f^v%US4f7IsF3cUl3L7xAuaQ zI=6Qy-KR(Dc4T!|Jv4kq<=6Gu%0+?_2dRrMQsioJplrps5WzQus!q>ohGm9Li)My> zhRd6@c@dKm{elH&5W7`C7S)?PF&Yx9z^=evMglgwSlzj(g>@<(`H5NWG~-RnoIZOUcU?S9OE~K!-voAjGLuNXY2=E_Lzz^t;P;az&IXP)(Zway-$J$| zPXDeUgANyf;|~Bp8Oj0wSO7o=Pbev2yTihFy>1e!3@_PJ#R9U_1pvid#1{Mo(~pH? z`OroYGkBK?jlCk+6X;K|5rv6V#~~&9Pq+b!zCcYlR-(4LCQ2tfEU)MhP}SyaE{TS@ zg8^V^9sllgg1Ndtwp{rYk6#iix09XJ0CrnoBKd>rZHgbLC}5!|1lT122apNSAI}p{ zvMm4{k$wq$`Z-i{$rBnLLhUO)I@HU1HU~vZHXH$BT4cXuwTSfK^Odj+D>X_{H*T4i zr9|r;z@R4j@1Jys5`PtghZ=^OMXs&(83Cs)uUjI9mafU%NB|Nwv!=y<%S{6b0HAA% ztv=ZVYwb2yPz%PBD&6Kf>MxkOwFsepNiM+`$c;J$^#Dx-)QNSPwztHJ>$2@0esgfu zjAe{XA45$~t6L(XG@nfaWbkOz?Zvim+qGOX+= z$laG>e-%+KIK;53jS>^uCIEn{zwNOO=#bjTfgS)DZ}amFSfH#^zlAD}-^oyJ3voYz z4B8TJu4cDV04a!2M;YyqV+HY;*R~965>|m$oLs=Mf}A%W6*1Ha){&1`UK1o4Q zCmgF77g0i=`6`3P5GAnhjCRIc9hw;;38?|KSOu^GIANEan8PD*PS5IUp5RVVNF0cy zI1zHho|_x;-JW`k{!a&LOgvSot?B=!`U2w|q8w5e^<$jiYaqtkp%U8S>?>=ih_f@E zVaV)ysG;+f!+S*xp!7_zbl4`Oc>Zxc;XcXWoz3pI>d%;685mj_`Zn~VXJ+2WP+MZW z5C!yU{R-C}TAw%R`3Hto1*eLLG_WGRl>pZgn8B3t3on7D-4=`aiETn|)Q@@af6@RW zBbA%~4-zmk4?jG6gxwNqQuI9jaEcA_L*d*GOQUFv5b&3;GUm9rC!ReuQ&y+;H~A7# zFdx$?2>9d5aBm(mZ!r%op?l%;A`q0U(D@=-8A+C(BJg%a@(ZA~(q>&R;!9mS0zAhBIYpaZNj8Y|>>Qsczm^ZlbN#F`X4TTQ1j?QKZLl(0<75Kfcru?fk z!0jI#`x!vlg<9*!pydz$V&*gy8bTeqW;H?p-;h6j6yy#7RElWT&GlWYA_f ze0~^2b@88_w(@7)LLIdVjl3B_p-x9Kv+ia+Xq9VKYQ5EZxAj4mW~*kaZrs$Of{wP) zhU0IOUy8WXECZdoCDTu+U+nVQlwonVmLcEaYbM*&^`BaGz-fuHnZyu1-4H!7H{+{~ zt!l9I{d+6zqO|`(ZCC&41E}EwfWOa$1*qHGO8xUEbOs9MRllsqwCgIPv*0%%B2N(v#d$Db}M@{uA-CS{v)nuj3 z(G4&r95Gx6HlDG8Gb8Rw1g{^FNbIESk%gufPwKWeTatToGPySM%yIIWf7#_lpk~)! zrx39JKc|%8+lk*y*tLM+YhK*hq1weLACnVx=nO#Ze^e_9e7O?g{N884XV5v%Ip4X! zXT-Vi6e-2zF!$un{yvZU{U5j16-=_y6RTJ5_J^9r1alCM6@=dbKdUp}HnQtJ{*#hx zt7^rb-3-9hwuV(h$WOrl*3>$KzTF_tjqNWOMO{vQ*Egd61&C@>ro|zY2R{~0i*qzZ zzw#DbOHvxvqX^xsdPglW;BTO1IcbJO1Q9JOc$O5*@K^(W3sHdJ>p%*DdV!`_ z^a8B{9hH2PUZJ!@eu(rlENi2HfAOJ6W>&KHT2Dwa*VyTSd;;x%`-f1UtJ(udPu1Xc ItVQVm02LOK$N&HU literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame02_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame02_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..14badfbd0581d55e6d45adb75da53db215b71dc5 GIT binary patch literal 31552 zcmYhjcU)8V{y+XcCxaw_Bq3pH4H*bnHH6{l@DRcXjvhizSnU-dVML+~8QS}~9FUP9 zwjm}6Xm6u}lHdTV)LEjnUTv*Kt6mk`(iU5xt`6(weBa#PKfm}$$T-P)zhCqDdcI!A z7&d~^S%BLJ=#@Z(MF4=X&49sRL?{6e0aARn;Or$cKRQf#^w&3jrM2$cC?XP61x1!3 zTa?9oGAwnMboo$e(~j@1v41Ulttgk(*`v1S(I`BnbUtjUfr^Q>2|Y;sv928PAHTY%QHo;-d?*>$>C6S-G%g(`BFRHPJFzy zu8w-L{b=Vlp^zEi!MMNA!s#F-!6Ojin>=O^02hMESttS!zyjv%~J8|MrLDi;CS#T0PNGHoEONxu>k<VoqeO>zBAHMt7 z(*Gm=Yaq(g86J8D8p+}`Cde5SHT7zEfw$L}&V6ZpbVDplxhQWT|JAAErEg)h9SRyJ z0thfxss{jnk`+OB0<_+|90Q_PKM(DgTPm_=<%`1Me zpuUmGN?b&Cb|2tgd~ic&no(3n)lE&kd>PZecD!)@-xaqhPGqF(L0hs|L{oq$j;j|T zYKe%LV%2gh5*j5u&12vIoW|+@#8sGTxe5`CAW4A;R3C$ujCxo-fDUYZsw>QSW9Lhj z?Ts~VKXQT7NY$;?NSE_V0O)a(F1H(FuO~UG+}4Y`J5o!o`+R%4A@bd~k0MN$No`A5 z*4mS1nv)tg!aP|vV;jL;1e73N5C*s^5JZb05hviX!10M5Et44#{MsTXskf3SoNXMf=Pf7}*42=xKN4l!Q-myAH|Ts2(0Z5$Ye7vAZz)n{Q4&yyYqB zxT@DHnf_s0q#M?btN4FYzuwLZ{q#yylWDYi-nX0`B)bbl5=2&%|KL1ZUrDQ3zk!h` zM156&22Hn#gjP!jEV?uZ8MqylZljB28#@x6ivyY$DhtI0Ug^yd3LRQ56?w5xMiX2) z7kVnVEBPt!t!wq$(LQ}&+}`%0`sA{>a#`ry60Cxfa9!eRm$q4>0OKo$u7tR(7f(i* zvEfUvRHeF%44oXg1hjBp(bV7inCgPS2NyAbwxS&NoCgmsUfio!cV10JcwYlCMuh!` z4kN^6x{5o(KzIx~9r&;?knXT^iEcOG0am&9XSnyi2fuvq_+5rMppzktt>`+k<<|E7 zBQO{=je8<9Y|<%;BLJ6(_`SmwM2 zi#;`1pvW-h6e1`C01^QExJm{v+m%ig6frvO2f$kd=z0Fi76bedU^#5U*xwhd3k3qT zWS3NJlisI3rDB?5)=M_D=h?Uknmtt$=0j0)I|g$K4aopeF;^f!go+UHm*zf14j351 zB|Y$52#YRa!Nes356wVM7ijgz#;vO048Qq@$4QM=xv?(32TzkthZrAtV?J@F1^aOb7;`yVHT0+bO{q zLcOqL+&XSe@Pf*k-N3Ln1)o|?WjbH^74b6|bl-TBW^@bqL2}A?2sVMFZo;Wc8 zCx##V`RZW|^|CvBp8ui7clpL+H34aik}Hc&2M!WCZPs@$bjK9QX297g&5%ZV0)Q<- z?ZIqldp?r_v}hECT4Df^2Owwur#FDi!bDuaN-8t3{aI*9I7TRN7PnT|*Rg!<*y=2g@>%ESZnE-U6;tF}WRbN*sX%#62}E^RX( z(S*OJkw&sPoQtV+@w8d>7z~b(Kn4Sx2ScWxvqsV0t$SDZt?!Gs&{}?X zSHTzbdVx?2Pz<5-oAm4 zQqpVJ3WUue^ZvTijVA;y(3QA)Vm6_x6ZdMn{*s!`95%fTEpb8OqD^Z7rg!04&F#nUSObFrZU`Bzbs2g|wb;mDlbzY}BW|n`A$%&aSXjTIlA0nP(Gbo(UUh zh*Z(FsftqD?R!)m8pNHmB&Z02*_tAATyZKN6IBZIyauIaGfSFdkFW>xi~+(X1_t6V zdOKa44w5vHEG^)|U<{)#RyLClqzDOUA{2%g7ACqlZpYJ87JPQc@6F$@zkEa@kH#8a zPw-msnXA{HuA|I49gIgGy|=}5#a5k}&t5jv*Gmu| zf{c1YiQZ6%WD|z;y1IkITCG->F>rYI(rdN>)A9gY(*0{|-&yyG;f*~aSLOys`W%0i zw(MPc0+0iI76bIrpj`p;B7klAN5rQ2$SzT8L-yW+F;&{@pKI>X-}y^F-yPpAvj}Qw zt@&(C&_YqHhr~c=y>x;)WQ(|<2^R(_L;%IBb{?A9N#ic&X0w#l8X`xSe(a_$P^Sw$ zmA*%pCN(-R%`Ah4&@=^L+@vrD#+ZpgyTPnMOa#yYc;qw10zt~fm#BZ6f%n_b@4oi< z+OoqVoBW>cJ~%~NF!kIuX3hPw|Fc(N1xRbYdUqzSG<+K$sqR!=rpw~=s&wBc_O24Q zgTNd=_X#C2eE73~$Kq+rb&5PzC%D}04Bo7$1UtFl1*Id1B&Y@wft$BlM@A9@q%8u6 zz`7=I3sxNpFxu8oH{i-hIrkUuKA1#2FzkTSz#}5_prT1+S^{~u($6P;%6B9r98T;% z99_lL2W5yfu>(tX{onQHA5$-Xpl)@TZZ?xJ4>z?C%e1O8IVGH3#}z`>}b$z1d0V5JLrobsm+ts+tc{Mzm+|;k90Zml_X%M8b5U}(KC&yT#?hvrsL7Q1X z3P}Gdi9v!CCRsLgpP=!RMH?9XSrJZ;z{-Zuj37RhShF?oHkYRZ4>nrhje%*dK2d(OG9O;!d7 z9^OCr!#5v>d|ECEOrfY_FYBm}*h8Fmn5*jP7{KCHbBI79h@r%_cpzyJsN2u6-a*xN zff|sBiC%sp1j9VdrW?sOu83Xyi{Ir}DeS8^$u|{-m$0&9QlfWgmmSIXE)nE#Q$;D! zQDew!9)x5p2P-@{jasK*BzPIkkmP7YY2iJ4f7;aZ}gQ`-^h1!BHw!qqb!5S&coX5iyzK-W^i|W{Q1AWHy*P{Z?M(jp<#!@n#w9w zorOBf^|nw@`h4mY)j~~I1=7VBDw2M8ZZ#WRh-NF28gx5r9EU_KW>BBD8im3{agl|7E8YKT>XW{ln0^Xn^ z<+&}>`lLbCKlxY#P4)dvJcR66vQ?_qVhpW^@%B- zE)T+kdU#?0@&MD!mF$u55(oop+wr{22KCoVz6y|J`Dby9$@R0eKdxALKHgAq;OCGO zZn7v-o+YOt(>EA6fZT_Mz81fF?set<(@ErvIIqmxlJlY8U8xUmLuK`M`s!ERwe1kM zZdWY+RLr$QcC)(fbSa*-L4zP31Zgx;tl7wZo?jy!MyUTIG$vMUfcJn*NKy zT+=KnXq=KICtMg^McP-ZG0zy(3voi6FWFs|n+n2{Nsig<1@tyOj`g`Njc zt{=dAJP0Eoz)V712lWrL0E&4>fDTgJD6UUz)|Mq@uF8SRyRhTD+coKyrmUSA&zQMv zNWf~M9?l1Tka62U>qA}1mUKQBX}j8vbT;zmxQsCyCE0XKLNCtg9pk6Y65)WP2jL!A z8ABn#Gm-II7%kP2se`+3BN@u(s$+d$bBh|17R79h*^6uwZ;L6`niE)wb5yI7R@^+S zeB-c1W(nw`Z=kiVZq4T?f-`AQ%$w=U1{%|fS*SK2lyBmLxNkM{*XQk4Mva}hzOAM) zvp9w;xx)okfY`P89ey)dFV;vBO8bw0bpN_a=rC%-9o;HJcHzvF4*iJn`ImECo_fG+ac%Wpa3muHxs1Ri2FtDA_zz@J2_g^nFRGAe$cH73~$x$Y*5XWo0bDw)(Ig^w2Yr zp}WF9KZN)Xkj^WS=?WSB_(+h+CXJM(h$UyjYHJg9^)(`cv}>Nh^7?2pe)~K*8c`uY z8)-u(o?5hsnW-<<^NY1C|A?ASvrZe5Oha(q|1pA~b;Gc6A`N$Y!-adiz+)KCAv6@7 ziZ4bZrd~cFnixVtI1mnNfwE;Mb6zA-l?b2$b96#R4nYV-+XmPqP{aro1{qe1ZZq9J zX9D!k!4c@ElWrpC2*CyVlo>M*NncDpI(_O`2_bee)mFd|Lbp92yczVh+wJPBdzDG? z!+iNp0GxCl_D_;20vaOyQ+fxjm*3>QbOj^2DjKRe7E$d=+NNvaCNh*+ z(M4Q{nZ6KJieT&o9+b>m+_bn_ki4tBCa;FjD$LTPwDx9w`+^%k?LYS5=IW@U@gs+} zgl3lR>gaKgo+}OQusrlR#-TobNKtF+-ag>gWu_BChs(u?kc}Sq&5)}T1=0I{nqR&8 z$!~Rp7xjFsR~=!np-)&<>yz)cK-iCxsb^RCL%mza=;1hDR_@=)wI?)b-rrp$E%z_x z7o(XEsLl#V>mMt2qb`A~psJ-JIXYRM5}^YK$?Ds5 z$c1L0m53g-SkQIy(5~39B1TP7XSO0Up(Zc2%zQ7|fP7*)>N?=I8C}Mn)CSon<<$3W z;4JvYJJ8$#H=vOaA>kz;`uMYR9lvZHerTMSa5=;!HoN(!UGtK}o{W*mzlaxPc=6VA zk%tZrEmMlVD&pjaWLO}_%%pJzayksRsoHqMCVB9jorpqIscg{}&nM>JQF5Y%84*Pl zHfhP1Q8U_t_i8%zZ{%^d?|aI*Uf0ogwWgo zANoUlU!M`>cK0KQkOaOFq!(DvoYpTU`u~bO()0A`(@C)4hq|rWWoM6Uwe6C2ue{aQ z_xwk!L1hV9E1_q2H6vUetmTn^1H!Hau2m5GFH`gc_*#`3NgRYFLLE$v*>`(-_8`oS zE+RLFNHflzi^{MGLH)>8Sm=XmSPfgwOdqn~vVb#u`chffA13M&j9slXe zJ>Bj;(`NVAIR|p0+{QauMFhlhJ=6k-$-_gBmHEf6)#-#uO^`G8;xc4E_6!UZee-z2 z-8-=;)qjw;FLS$Ft&oJ;f?VzB7^w0r$%@OTrTEv>M{rZnJ(6Q_>lvGv4a@|F5)E3S z+o$;gNfhao%^9`());eoHJo=>o(_bSNzJ07gqDm5dQN9@P#mizqBCSrBGYV-wQLnL zEU}rAc)kSL)+JO7q8KBKY*N3tKOJA`^7-_vkIv5h{mqvYuC-s=1GSW<$HoFa%QP9WICc@xlqM3tV{f^~$zej`)JbM=k)tpDrrHRZ^R zs*Hc+goGH|dHdVHWD(j-9rf_P)T^oC7w(*>fO-rX3=Lgug@5N#N$XoR1+~>S4 zW@W7U-`dKMutC0L3!bjqj`PvhP{l@Wck=Bl*&}pgJ7gO$(&_Bk84#cg`0kwPawCh~ z_S1`B9(?l7lJJ^aCht$y>`fSdbLG$R3C8}8E6w!Kg{CWw;4@<(kp9OsDxeoz@(&!1 zKlpI+s>|hW&y686VmBpQyvz}J0IjCRoLMbIW2?>iEn3J3|9o=4rg5Poq~unIGNh|L zsH=T?#SDQ_(N*15dxYK#n_wHz%eb$Q2U!uh9wYHN>-l^w&472T?{mhc*`<;FYobE@ z-JQzdt+wzIepr~1T@}_LZ&51CGunllkv(Pj7F8sh%9SV%+*4JO$1;N2}5mwvAhWA*h#+NU{B}iC-pe zsBF<^N>FWe{l={|smwQYONuUp!L7?gfusm{c;>KLRErjvmf6CCSHq^oxN?TjXzbv5 z4>YFQo>0%5=4=o@z%^$N%^Pi4o*T>2oC;9ge@Fpwg+y&j9boI1{|$ItNs-u+w2y}@ z(uHyrfpINKE&3H__kDL<*xS+5QWzP}ogKSmU;5~R*DsEJ{mS>g?r(CRA*XIY%(p0-yOsX3(K#F`DtXD zkAZR{0odo!M4q^N5o?aZstwvYH9UULg zSx8hU3}7IBfDkf#r;`B&;MW0N1Vn&4Mq)tIA~hsxa(YM-#MQ^yk}9x#eX62O>I<~I z7G4LxfIlEzrm^uIO8Hfu>{ruk>**V#gkbHn4s&B^$Q+^ZlX$6msH7ovGHR)|)>R*i z8bIxlS*L$iR?w8_>awhILyYBe@TtDS;Lbo{n2PpwXZr{ftT|!o5rk200x*sb-3r+v zV&Fg8mlOGMVtT!7!@9ah9zu&NwS=R0&rfmx_))kM=g3Bo0vg1FQKEC@H$IuvcEw1B z*M)}q&%ga_%L{!%zDc*-6<-pVipWIut&jhB`0<9g5@{+w7TKT(%9xX^pnE~7W3X}k z^Nl6&NvIP7%b{uUolyf&4V!TPAc%Ar-<$}V=ERkHo$xnaM11qI?a!@4*=L8z0BS2ZH9#QJBL-Q5e!;8k*L3UZ;OcT)CE?98(lqk;=^k+~++w ze$8&Zx(0ohe=@dp!$i=BmA{YpXJ9dKb5MN$=jrBMu9J|9)_R;#K`h|uX{REK_PD_;qQxpo#)y-sLVTv|L>B8@5 zXN+<|-e|n3$=JMb13hI>wzJ;RUT~qqo!uS3(LWWn1Z0MBm0_{-yitH7WLJ+Gu*zyO z0um(=$5PYmsmmY;_V%}u81{;?NnJKk@LYO0*&rpIeZ5#?DtFd?l0WY%&RDwOon?4> zPY;G+R{qWpWa%@!p|Na1BgUFeCml|;>C+%A*#o9LRF8uoTrP)|8eSKmgT4&+0X-3V zqz5QfFmi^Qq+kZRyM7Fww0w+Sjn9qQKFHqQwyyMGY4G;m3)bcD;dAdDTB%f`Gg>7E zajgIicIV(DqRD*)_o#)@1aXZK2IMeWk?3x^KQJGw48>D($h8k zLW5BF%K~{=w+~UoYlw-#{A1P=tU|yQ(P&ELY)_;SghZ%2M2uaf+8~2@Xp2;>SrF=V z8Zb*P_q9`gZlkf-5}p;C#jmCDa5(D%0qs7}6sN#w)vM1tj;~*EY?%+RUA|Bn`az#k zt9)Y@y@uhX@GAD#Zs?6@_OC{0>%*$@TGpGX7nys1LKgfuL#9wojv;g?D61B0W`1B! zPw(wT-Ua>`^WHd*Tav8>uP_B%Aou=w)P<13&&b4G_uqkH%eoN>$u#As&#sg}-&fS-tbAVNcY zfCrk1wOEyE6_mWaG9#nPn(pP_s?gI(q?im~fzoS38H^#oyU<7SgAB%P)wsoe(;6N6 zi8?P)Az3r1cG8y*(3JtUECo!gS8f@N?;et-iX(YEXa~@`xX`jN5}@bDt`DtWHXjiI zxC}X;*GE(ZRde00iE#u@ZE&ziX^IWw zMYJAP!$s^Q6L4;Bw-X6+`q*=FLi1)1{r7r%V{uZl2sCYGcfNYnu}tJt@<^<)_{t*N zzh-+Olv}5{_trmtj(+>IvX2w?XSC(d!T@xuJbTsd}tjKKV?L>|?6}P0;`p-dXt=@nv=J&(3GY1CzC~*ejUJts8_O*HcIA5tX(VRm*DN-4 z((|%_(kmLOo(6|*CZ3LsKy)4uq6;yG`2EiHyWiRN_07&L4ctm$?c!R!ZcKJWxxliW zwfvkaD`AdGu41aFIv&u4EP;hQ%||4MC))wu{a3mt0aVtu3Gxj?2ynw-sX!fg^g?PHuHEP`+v{b zv{>PY?t$Zb8<@;wU20?M<%ppw-J0g`_Aig>rDtYP&mDlGjKZIX%58Rl&MD=PfQy(} zH#^lg8%xp805VsBtQqVmx%2q)vH?107s;KwGh+!O%IzW$Vck^Pp_1`uPdbgqq;CXf zAQo6tL(VH1;uaNqETN)%!*`1dhyImaD}fl(>5J@u^S_^q^_D1hg!W z@0y%in&AO#F39u(A~G41{lrII3pZB{>iJ|Ii0y_4kL7vDb;o+sYp9^j`!9#IEn^?a zc*7C8eft_Evd|zFwq(FjNbmd19Swnb)KX)BFAV%F5j;UEAo=a#rXc_6N)GZ+(YB~6 zewYx#0O07gl3w2PkB(pfMBd(Zrdp7fvc5x86kW?z8d=$(L6}%^-rRN}XunJgV{$S? z8zlG(JoV6)%sqStcct%1EP@{s;8C~3%S;R0vJcfe7EjK$q zl#ULyOE;yK=IV5bM@(>_Nv+V$OrD`BheqQiFMdRe_&=o~wXsJXxMkn$z~1{WsTUt9 zBkc;d-QMe(_SUGqHd#7KgETFGFy~_P3Mhiy*yPQ2)?5yXjm6A{E$v|5{tv2Cdb~eB z9@rV)3;D%Hxd;JkHVu*9W_5#9)i9sE-I?YD-yL``;q&(zI7>218*?B3a2d|1IujYm z3%+1w@e9mgJ?x8`VT@PVQ`@DcmsAYpsgZ}tWU{y(i1|@(W|S4m*xyyvfX4&vvD?*s z2~`rzys;xo5z9sT@eK*Z%78cs-~_NQkqtzU0UiK6AW9@^3;8@GELL2c39PBX-&OF! zw;b3{r=6&E>7+bfkvThYnu=b#7zP0FfQ7M0`fvs)eM5cp$%o(HXn#UY3JGGjw;EXK z`;Q?sfx1XQYZAj7ZR``iC=yH8DIFVQUknWlXGdbuv1|nN~Oh8)IPB`6lOS3+~^)_Ufbs)2!F^WUfaKP2}J>{A|7o* zxpY(o1AT;7#gAt%IN-FK;R{{qx18}aVTou2EU)b8p+_`((OH-i&{P2;0B{)ZMjpL9 z>cH7E<)YmfbGZl3Ao|f@AA_8q?>|&BDmftJ{npk@=CEOWZHA!P2aIqbp+=7g@6I~Z z9&7dD)Y8`}{52g3-L%cv`kav9>?M<~-VElnXGRFOjL=PI!%3iF=dlHqfMwO%aq5Bl z%ZD$Bsa4ch1MJNM4(+t65a*`-0JaqVcxdvrrvJJ&lv&E-{k&v;sKwM=Q;@`m?)QuR z1hmx}MQvPvFsAF}YE{WeNxbsM(+nqQcT&R?%hQ`^n#c9aQb2nBbKmhr?m%m*kmW)?Cp9*&Km~=WfqbLIQeDdO7zYF@fDWVoy5x`?v zva0uO{Q*34s^v!Uz7s?3p+_fw>+UZ4D=|L9&mUuagh!!!6|Q=G^?Cq34cafgD@ltT zboY_J4o{-r`27jzBO%BA7c*u6@rKl$lA5i|^0P11%lj)R( zhyWPJAt&_aZYBsZi+ckY<=&W1ZrAua9 zuWg_Gr%-kL;u&XTM}X5hYzls zn`qTK`rYet8A_3=vA9Lu+11I-mPk1rta&?2dK?a6w$a$AQ-$mO8`ukvhxZr~SU4Or z#7G4RVoQTPpfn+%Hex-&*U25yNZZXDWZM3}Sbm1w+}t%#ydiV9tQlbL9gDlrb#e1? zI*m31CIkfagLVG66+vKU1fMdxLa3?9@yVR1b`(cg;nN_4IRckyW}REN>hG1e0s@&x z`}FYP@KRnI9D10d5Y!`wJGs~co2oek1Jpt80OBSLLsiQgi$yw7^6ExSWva_2%eI-W zCYwC$eowdnM`o&2n_;rw-NFv0sTx%>Jh~_eVbP#$=uki&mNiVR(FF(@ekmD_`wPR< zQMwW<_&VqD&$Nd(2Dcv`lExN=8Rl>;uOI8k3qA61$J{jshqHNumuiceIF;-{rnCvc zz$}uJ<^A=TSA;07Ot7%oFRMu5ME^cu)0ti&#(4X20!g=)wM*4NKJA|4tX2#G!g@0p-u!Ni`8@EP zpDK^p0;o$6$rrPM+vSistxhLkq3r*Gb%2+ECxDD#_&6KFFxJ=HkB)I>f8Y7_3F=az zq42;tpN~E4nTjwdaIUfm)D+YNhQEzB+!XnUpv8DRmhGMZX zN7yWzxWv!`lwk^q#9Wl2;z7^T&nkgytTn{a6g)9+9O6JLkoSdEiEYV>IaRfIWnfa# z?L})tT1uzqbX|;EojJvlBH>mw=oe}O0cc^-@{|Ph5r7h~c!H<)pb-$EM0K@adRk0l z394ls8THCcZgI>eX2IRYipXsh^$7@4HS&*rG0$~ljQ6m)`8!e0>%M%*m6|X?dfmS@_}?%z`&gI8n+q*O<#_&5TVTJh^yDtO3mEUky8w1 z3VZ99+G1+V(>Td$VWT3-KMxXME{i3K4#P8Gz#V(R6Wj&ZGYTIvjVG^#O!q?g&N#eF zTr=R~N*LwjunywkVS&*?C0CYSkhD$KoExGe^ee8Hez9@Zw~Tx_+Ng-|mL=ydHSRuJ ztG08lI8l-WKF~HzK8B!Y&XG)g($(;VjBvpQZL#u#lb+Tm1uQv4 z7!YTW2-H6O1zHaVd`jbnZZLtde@Nn;2PNINJCpbU4v@mnLC^Gqvk6W@8=+ok0<=B2 z{UWGzHsm?KXSLUaP3iht-NY`p`!Zzv(;zSGboSNPEM%p9$pRw*S2|MR*XMPq;A5z> z1IQ(dZ8rlD>R2{HFSTgGO99~)i6DzB!^cxK&OC8{~gZDjGo)i zV4V9>JO`Cx%x#OA`~V`UajX6CypmC5z}NQ6bp}L~7z#!Um+4)mD}^OXpHDadipp}j z5B68q#KhFp0qIgM)Sw+!lWrLHDG3}$vq^y*iSC@13M!h9!D^j8HYCk;`hQjOQM+Z^ zqtIDj?Eb*baRYxjBQ0wFXWK+Af@c3J7H16jVhlzZ)Fusst7XB!0mrMrOt@Z1^z4g` z2QVihpclRzZC26YM&Op7*}+(BOic?Y^mnN-o9+5g@E9s93x;1p_PG1{@{LBf%+c$b zqNa0HOeKZ^x#o}wD%axU6tThWUGV4rciVId0Jc|sTv9j-!xx4nS0tNYW@=3@5QnR; zNFSd%HE$^2a2tf&fk#jCxtBSO@L6|lVPOUzV4cT{k}#o!R~Q(nwuxr))$Mjw2yAwp zc2=nE(xiIyj{x=b7qOL4U?hAN1nAB_43)y!*2mW-ImP-xrRm#WIOu9TY1flEt%%bYXg4I+QcVIS-JjA^;ry z_b)Q5F8kCItP=BEWn1>N&VN!}?%G~*?CD>;ANRq$phq(JsR1dl>l7Ux&EUZ}0Vl@+ z7j5(bKo2)oz|g@yVjRrzjOT7Qz??sJEQfDH|5&L7WSKO!Fw9_%xbpK`pXIvkQ6GK( zdVh|t>rvg>2^);;)C;PN(n4N@`hZ$W(WaX!vtefZ|Nr|CzDKbr zKkAz&6!%Z^B@l6&0T2PEx&CnFoy_N<>M#FEV zo-sL1qcJf&G?}Pvp;=9u!Xa;wX-Ipksms~opC|RFdC>g8!Rc*jKTtvQ>cr-Y`NTx` ziW`k(TZ=PlVrs+ae=eo=q2Nie4zo#XK5a+#NUf?v`u%NcjqUIMux#A@wCQctlNk05 zHDl_}Dc~pl>f6JwOusY>z0t8deT*Te9JeOaN~(jMK&y-N1m*F=BlG(QEp11C+)<{n zj_f#Nib$zI_LCegfP5UYGbl4Q5f%y5D%g#NmXJk(KB5k|wV8n9h7Qe-0%OoC#=s-+ z651fN{|*BD312~%gzrCf!V3ujorK*4_Y{G-4-y0(^@K`DNN7ih{OW=hvnsCSogE6s ztY~QN>YJZYZ>UGrJe%~>756{=_wSC`(DBG9k05lh5nyij)mPL3FBAoC8VOI(9;?pt z0#Y`N;SA_%+6BX}C=kts4hk)BIp)l(Upxf$8c8$*b3{T|2mL%^Kn9 z-+vd@JDtuP9g#!8$((-Z>(JjP-CB@_FDrfPP&7>zT_|fwZa2`glw}fC3$w}$uT`3M zF9A0HFIo=63xv_GvR%dQ+0u5^S``=A4y%Dj=vL?d$Fg9*`i$ah5omuCgR(1n@&Lc4n{*WGk2G_$n&+bA~T^y_6B9nxW_eFc_VG3HdJd_|-hBE+$5jAt-C^23+Z_vqSJ*^Ysoujt;HR ze>YQl^SXBr1)QnU-RjS;o7$hH5U;Lja7dGRP;<~5hg?&Bv>ce*HD@B{c^y{ol5Rbt zhNQoyl(Fb>#)8@+9RLXlij{ozEH#?m58V*-!=)G3cf0n6!2B2P1iok0XMEvRf{$OG z+PiLU2$Ka?G^E30;5rHzd2p&ZYHmN29fp#C90@uMFJIO6x+fozy}he)jP8ZJx8I)q z&e+tS5Wug=$<3KQ^4F&Kb!LI^j71J1#RpmcC>G@>Nq(TczS^PJWaT`7$z6qdd* z6yn{4c+DP$C$heLHZ=t&xd^y#OBAZ+^PsoFAPitP8!&&k1Y6SUs4}I1>ln7$mkgk^p4pKg{z5?S1_6xI%z6 z*Q)dgu1H9gWW@$TCUd9NX(gI^CxR9~fBbl^{=bnZxUtnP1$m)UE<1kk$kh?qge`tU zW!-70=uNDF@ex_8iNazs433tHphSfM_Ifakh(nZ}MSUCiP4nm(^I=4|1E**qT5B`Y zRDh1rmx&WT?;DIsM0w3EotZysKb`VJ6@cJ3Cl#ejx*Q*D%$*oHO)FRyP#P9MEkuA8 zfWC3N6}y7Uu2-y$JO($+`2VfYXmIW5(3a6fSNU_}*IWT#{C=_@X3k6S3MG!&Y_{skLm@rhJMs1mUi;<# zQ?{A?N0eXPFmSyjtgz15E1i9o*Mq;<-kbV<0A117*(!|OFMji?>$_WKvl8CCswzMI z^VYo2&V^pv-zlwDWn>|=tzuZcmf*2yJhEtOt)bw+xs47VjBP(_T-`p%iQEE*Ttdi; zRG=HSBvY@7^@D?V#w#rBv(x%p90M>9c?X-(gE0a5Hw2zr5RMs% z8Hs;~n{M^(f9AK8)~{nSd;m2SQ^ybzkGUWX!$cF;uiu!Wo=>dHU3C)PzJNxMWi+mV zS946joBT=Wx?u{Vt7vBsy<8#bZ%^BDJ7amoXRn?WX*;e5TM(qRrkR1 z0@OnUxm2mdu(dzNhZ`ygf3jFvtEg4bgLu_(g@phlf<#&E>PNZKTSM*pavc4UF835C znX*g0YuQtGR$Yl7TD*A5kMdsN-7{3uZHFH6(C0X8B0RiA2y@^JqPP`B z9~|4a|5ji71y<6!;nIOer+@$AGc?YQ-ihZB@r{NbF8-?HwA3b! zwE6!Io;rG?P{ecckn?J6q;6+(rqPm@2Ar3af%WvUj%ecn&-F1^% z1tfC*8n?TZ#1Mw>_hY`7#$tGX9X@r$4=kDJbrguS+(pcmf{)gxO*B@*{KO$KR`CW< zOv`~JVFE!C6+{66?vm&Cor$`4X8v1KZ@=|M%VbN&u>{44iS3eZH4HB%p?0y+k=mrQ5}cn%S^ot2A=-?DQ}LWhY?&2?a-10NAM* zZ(7szGV#Y&%u8A8J)ow+t+$cX!;5Vo zIyxpK#BF3Bi^jtZzub9t^YP=yxGqPx4*KVyF3VjUy5?ZN2VoeWP4wWs4g#lUP!DVT z*9{LJ++W0?!_snEcVHk2V!b=CPF>h*`S|JizS0YwgD+0K8l^f;yFKh76N2ahc@znG z5JZ_bYaB=u`8{#f0%4P*VBi;@?{8nb^xg4Oqf=8;tKb|T1Go3|rg&wtn($vHp&swC zgX~*l)o@r4bM->J>L#>HLEXpwQSiUuD84;pDNu^0UIeCj<~@a%VNgH(fHk&BBg@r6 z*cCi#-nUUd5=QrwyAO6q36M!RKj>Z$z$?Ix zZ{bSW(H-e6T*$`I#}HmLsV*zw#>vC~iLDW3Zw=a+dPcfzSLy?by7$&kvgAKf7ejkl z0Bpp%&7vPY^7Zwgyj9X=?7ZEnpAGGw{bJSrS9ROZ z{(X>%9PE+KV`EXfp*~^i`ey3Vy_Xl;eqRcQouGjWbH0q|J2T#=N0UEz?qEzjK7zlx zeR9Q}n6N`83Re2-N*AgHgL2V->L7`ovTm&?zsDO62L_C-$z9GR+O{q(%WCYIi5?5d6Yn*_jkjmCIc6iPZNU4 zwsM5QO>^e_e6Q8Id2`(SsWZOU6@*sShImKwDpHD4KE+zCeRmzAomF=8OtY-m&u6!H zSpeaZ!*3;G$cDd%vimEniyc&%i@p8tcI5fq$lLX2;dFUs#B=z^A0ZM!SJm77&yJha zW%}~9J_OzTUX9sBqP3u$gYc}?x6IWiD=+FV`>blG+x)%_Zdv~IiMr*hWh*=^N=uf% z*XO_Vb~GDS{oQ`dZm{2|=Wd~r?hg0zIRUpwDgyL-niqE2?Z52#?xIssMxl&L4FnUy zkxYVvRjVYweb)Y{^C2*GwDl1JMeCUGQsV3qMicwTzinUM_wx^B=IA+JG#xg4vGCU3 z?+f!9Eh#QCmMbE{`T2+<5O3Y6m3T8(fow-K2yP1pcJ2_Kh&pJ^Hiv5!&`+`}!g|xL5>1I%0_&m~q4gk} zzQ`-bDQ9+FP;O&=`b4v{`-~XKH)H2u7bil*jo7<)?|U?IIZnh%k1#E^I%4=v>aEDv zCj=x+NcBx=fiMpRrUW!a$q5lbWl`FEgeVksJ>AC*{tBtfR0-99+#`y3E2{lxI%#RF zWlE=G!x(t}nx1PlKG^hT%-83h49Vrk@yLuy%#6})oKROIY((Xli-b7oD}zrbltfPA zQKSTOp4S$E{X$E$C!7I7=3E7hzrmc#8f#J{s`&Gi?`NchAGuW=X`2WG^T6S1eCm5d z8wv5Dh+eNYKaKXjVUdKUoSU|zw#+rwE| zT6St&(wi#jj>_+R$-1^Hv1Pe^OFs$=Ro#1S$P(pbSb3&#)eMrLO%H!g)LQxh9d!HI z+I#+7;_kqeZX(8UnRPN^*QHowud^|-7+80}NkXcR{!pq)@7oYRZq@B*_|}c|qmDK|j|YYf93#43iT%*~tw&392MY z5(^Us2j#`2$ea{@Q0?Jmra0jwldHfbt0JQbkBve}xqAHlI^*^jYhq`8Ow;kTEp;00 zS*=FF?JBf{sqo=@Ry+25g{y z97{J}67zE1m*W&4Oq4PEnQb~MPAb;U^t#{Cn^=)~%*mqmu#x@HVNgZj=UT*hA2Du% zR%>hjMMrERnn`n0&A<3n%EV)hLS>CC@3-d0^b||&;Z2H=ong0|Q;vlO zHP)+L6uNBMs%_EfO>_2yZkU+ILo8ZN)O0eb%O~IzqNOR~f674YVST}sKn{JEgwe=0 zziy<4NGX!X>|=9+sww&h9x-^biHPag6b@Y7hDEjU<(zED1qj}i^LHrVr6WR(L@41dgsYMh{9Nt zHN_#&ch`p~Rvx$38yK!I=w8{vf36Q294sy-f+G_e62FPezzRkkD*TxvzE zL*$s=hBP5&TM3fv4?tx;<~avL6|TbWU;GQ=I2QIvP^QA$$V*F4e`S9nuEz7KZl z^;HQ)s+Cg>NglG1`}IZ_SqH;BrM&&}2lyfvQK;pR0chD0ZapPq;j;>X74zMXg$Yf~ zH6GPkWPVt!W*K3g#jGL7FnN<2jQw&hDA4HyyzN4egy?r%FuH|vC7c3(@V1aO!TGCl zO_iHrH?$q@&26$U4AKwmhKYq_+Ep(z%(r*@zeJTBtE(tOtmXSwrQ&Og?8LO&H>OYe z>SdGQd)QA+93zSK(4CVdS!B0aDCxp>#EA_YkWK!J+lS# zc;x*=(@w*-M5%gCK%@-~#dK0mlC-Z(xgj*DNog@voK7w1vwv(jK&9t>eRXIgrUMai zgy0d=ZUy0&Xz$(l!T(gA+vR0|VB}vQOx@w_HHPly(u~4BWkGknOy9H$aD<&mw0Y;Y zei}j*Cj^!h6MdF{6$!TtKdlwruhT7N=oYnBXC#LQ-(W+Blw>U0WR6fcy1HJ!{%?j2 zk`K*dCQM*QsoFmt`@4kn-sfQS&e-76lhk5GaZT}-A|5A_r*Nv2cJVfoMpV zI2Q~^RbC;REhm!#kBa>#svLq%sK!5r&+e4smK`?lF#yX+_+B|4fR z#z%~nRQ(nBQ6>%$|CvS6n~k_G-OLq^ zqqjcEB8gQVHmGF6gTY68E0T5%B)WtI!Bo~M3KSem^pS|3wzJn5ie{!->7J3R3{Y+0 zE%E?G4nbsPPcXn;dp^EIx^AV>FUWwB>iaI8+aHp7=4THNb6?^Oi$*r^>`s7GOr>U5 zn)qC(0do+BWeA!g0i{`(LH44^USF#`#~M4!fN zE^;QSy9g|fNnID$!^GvJ*c1Bxs>8(y?$xE!h~HB*8A4V%UGQ)nQx*G;;g(%hrkp>J z^6*>qSJLg35+cFc7QM>PwQena-t6r?{AB0_)6!xK1yGNa)!^_##XHvo5~Q&WbM2P| z@MfI`NNjla@)f3>HH?TMUpzs)Ot*)(JYlPLSq|V@xsoL5EU9;ZIO4~vQ|B&z`G{$& zF(yCDA`%kVeGLkc4FRLY5_BB21o=`uZmKXZ)kRg^+wW0ffT(g{i%r=E(p< z;%`+@wMO1Fh0|89OZ-y(E5I@|bS)p8L6$xpJ`0({m7K`Wu#j|GYt_>5_jDLL5;me{ zh;@qeKf2$^Ohy`vSIP`q$x>$6Wm>3ARhTIx7O4vL2-=9}>I82Z=-VZPqfphCvHb)_ z#~XTdhB-vFXAQV)(HvYLj%7p^8a|$gMTe3^JyPE5P3mo;Oi_R~8(E7|2Dw_vq<$A? zSHJWwM7UPX3Qai@x>aw@C0z8e3R*JGV5+DBJ_%-#!W>&))q8r_XfjJrEo^UZ(=e_~ z_3z9eLmcIJFfTnH4J7f!6i@zZ_cX2RDXtT2@bSc9)M&QdO?dq~gK(d$Mw1O=qb|%w z6v=3^N7QYn+WhN=sltql3G7P3J_!+hkOGWw%LwPrs-iNY6r>aFjm)d7*~JNfOnW) zLR#u-YcC0u`0I@mhRTX0g(b_lsnyn$zK`tMX}dqNJ>#-MPY{ycfeXmT;H<`UD`-z~laz#__;HisiAAZ*j1w?}r7@)x z^ApmE-ZAbwooA6MFM_F1-i*z0H(r z3aAh(04Z#CAL7Dtc8>tgkX*$7JSYvfWQEB@IFg51eSzXYF;85Q-E=H=?{8;t#4nM) zOd2Q@#fjpxx7olP;z&(#QG+S^Mu(yAgrVv4E`4CjX!|SFVAj>$yMnS|SVn&}oO!0< z2cbt@ZIaYeTE~P`mIJFp1L@TI7Bto^Dt7~$y zk&yGg=R*70N;A6k>QBr_X*leTiKRbKTnUhJ%tzl6W+9J!gx|t|Byi=9rN+AiWyqJx z8`6~7ikTkEHkoX+(5*H5);hPld6r;RxLiV*SF58sP6zDsM#CN97C|746Wg%sj*VzH6WG_SDK`l@>N~*UuEh z#Kce#K>#%7!?Mt_TL=5|DzjR<+1lKD(9qk!%SkKto)v10+t=Dg|AHKtHeDBNH~ju~ zT0qgQSfQDxgOKfnb9D!OyIiMP7g);jeQ9n>ePOcan#6YHy9aagTf=j*J?8n8M6Fu% z`Q7?&zw0{MdvaCY`x91waAxGu{k#X`<4S(7-qYCl%|d~*`E`k*p>~<7Orks{ZnJ5# z#s7wUC_TP#li>@Yx0&LN<--1u3+x&p0t&6_z^@XJB9MrD2=9n;8cC6q60OcjTj%9O z{hfFcQ2r`>=$*{BPXW;*BC`T3fn;i8Z62g3V>f3)y^A{AkYA_U=t`K|oxD9zcg$68 z%Va+%(qFNvp+e=7R4H4z^ROd2Gh;YCGhuIRJ>ax!6d z=o&N|#PL$j z!MxKM+ZOZ&8=#yA|5T8xiT_6CZ0x!id)KEhiKDj%3%y6UfAhR$@77IUK_w9}F$N^Zw6w1ai2zITKyZSl7CaXFoXN5%s4a20ToWsZ6M~vVa@~!% z5FZYKM|4$y96cM?-5S$A_%1{bYgR(yPs5y=9WY+vw#aOT(DpX^e3egITN_Xx6j6^~ z)CemWb{=tJrKO+3trf0WSx~BXk+;Mn&xz;umvpT+i{IOp1o~_E#Be#6OB|KatVr~h zz*?F39TTR~ge8b+vf*1? zjYqy}^wnkz^K=N&x^TR-`onH2k0ATm?j#=1R8l-IQit5P%F;49mxS93Y=j)Iwp29} zb4cy%=Lv!JUae$;7)V(NPF;deJ^eYM#4lyHEJ1UfWE(9{y;#hDoJAHjmtEqnIFAXH zQf|lm9{OZT|fW*vJA`AqbbvV)}E6d_+pVkoHF%{1)+TN zc>|q)gQ1@;4dvZjgktB^9T56NTC-4P$D^aWl3e0E@X4ooB%=6YdHKPL>Y3R`qgG7sNB*r5^XaQb;5*#6-^X%dwZb1UIk>EE@l9op+%o5r4HxL=pg~;9k9Eprub(gUxDXJIn_TU#s;QcjdOJm$}2lQ2sRSQ>l;CX;$b=6C$$!XV-4eAz9Zr z0Ym^POgIW6$eQ7wTyZ-;pshMJjW4o>0#*v z;_Y2`!8@O(Ci@!rQGP z@jfrYdr+g%wn0SEv{CEg!qL{DelQ5%;1NC?+ap{Be~kkxCHb&tfPAxHz3{bbmG!3T zn^i@g>pjv$v&9{yrKP_zkD2l0WXyOJ&`wAvAxre9zk z6NO9l+Tas!o)MmbNnhb-ntE~h>YW{LZ;ahNvm#h;OBK!Ym>sk(E8TYTn#;ZQ7vH$| zFTXd|`Tu#!aBKQ2X2_XFK$9>}b!0ajKMvDl<|DOe@8gl?>vpYzFo@eSi2l@}U{#x@ zO%DU{Tgk|1F*jWG+Sk_(bv?g7El{m=IeSSfIt*S{Lw)zSG@h78qfBMUxrktC);QD8p zq>rLh#T`V1ytYD+<@wfyl#ueAtemXGd|lVNS9ciXIfJ|3jd(s-#a{H5N^t-czrvJO zmYc0zVqflx7WlFymf*%}NTTB>+J+hF4>jV(EqQZ~t22(hmui;%-I5ijkDr|~LHAQ& zs`*A!)8fr@t?{d5>T@z}cMzKy;o=IJh*1FFT7Zm#iA)%GCxGgQ7$i(s#ZlL1r(`|$ zd_pml+wsLy?a!H%mGT*s#5k4DHNzTPEOL$(S)yzk_T+At#pl;X2{&Cytkg{Xf(i`> z=*vi|j&X0K?l^%qv$p4ee2?r5JzR!887H+R(eD%=`WKb$*I*2m2!d&BbY`+`peQ9-Vw3 zZfcy>R<@U3-Cpq3c2cfP+N+$+JbKKegwbJIMVN%CVxI3rS4oYxwg-cPr8R<37RIVI z^a*YqsqW7y_~(u4zvK?M|M^;+uI}E1guqZ0X(?g*y2GmT>EYqS z_1kT&RnMO3bThT@Mqf`naO66`ek9xp&@2il#^uP@%H?bZ1}8{{_t_>7jBVXu%{C`R zg=%~>&YHo3b(YZ9f`Sh_dg^R1eaUBMW@U#ei8#2txL6)%zh}>ZlWk|VwB1Y`i2uvLyqsgwL+2uq>DWz}yUg=h&*ANa z7ZXu4=5v&BBa2**`?{D$A2Gi#Shd=}{ZaJesm$<M zL=SlCy5R>uTM=z|+2Z}{&bR5dc2##+nQ}a$1N=G^WjcxSAZ3x;szhiP-Bis0{Z&J41_OH)2w z+ta#imofe1vbEzr>d-03!;@B{nV9^KMfgW5(0Y%}_Bn%=C^bamW2~|;g&GWNO8}e& zh$Jo5LMM2q(^xYBmkl0a0m4%T!>;w$qgNTRKvfM{m3ytbf~&jp$Nf0)!w+V&Ijii= zJm*&5&w`lJWJnfX5hW0dw`9k!Hxu#3xWw5BqPWWVO>x=rCsuzN{lmQMKKtj@S0<^B zW!?6hu9^QyVc4NVhipOn#mEDoeE#OeRcxM(JSM0Tc=S5rj4%9UhQ4Emt%xwJ6;r|* zw{m56;0ClC&92B8dWeO2F=Lm8qW(8*Fh*yHH+!9@h%(!Sn zGvwa&7dE4-#?W;>m@_Wr%P&qdzQr>HC58E%iMg>!k=C96a;p zjzvvzv-q{|SDwrN*O%0cpM(>M;{L7C%;)aFwy{XdmUdCXr*jIzx47g(en{S4S7?0M;RK6KDOPH-#KA!mb`Jsk; z|JmyeF4NioX4u$vN=SE|{_7{n?mu?~eeu_w9d{+;_a6-FbR$*MRE3bP{f;^LPh_N@ z>qd}^LU-d<+V%oLbN3-<5fo~;R-5l_IleGO(IGBF_5QZV`lail55&KjweXvP&d$7z zf8AOiR1{b#Q3aq*c!{`z+7_uibZG5|guFI|6+5qEi`o{I8l{i&Se%%>eiqjwprqK8 zBa&+4x2?#@5jzDc?+q+yh*AZHT3~l+&IQThfR5FqX|;FY8y`G)&Gbq_eC0b9S>HjP^k>U=e3{`^{Xm0eR#sDGLzQY`!{%Mf)By@{Nlw3=tVW2s$8xHianFpaS~8PZ}HR{+z>0n6oqR-vFA zIK)ag?Zgsx0?BLCU=SKdcPDIgm(4W_$kVrmMdO+km05)6NC`6e$&&V=W7)C4(%-yg z$V-qI$8kx3B#O-qVE)Fq9s8SzZy`45hPXeAR{s^ac7(|LqPuXVCokTxZ0WJT+7+VF zZ744Gq|PLFdT$J`2nej#e1-~O)8U?!=7UaA<(wi>mN@>-w>PE-TjvGLmhz?EQiajs zI5@!hQ16ah7Uf@xcsq?_cB=stQ&}!qTM-~(qfCT(GoNN7NVlJ@dIbgZz;B|^;u&fY zZIfn8v!t1`WDO~;B$w(HY$uiEjq}oJ7vm10%{L@Ao0n#UH6@TN{1p|mwGlKj6bro?km#1T-L`tB9{bfnU4g- zNX^u~`gE8GB<(*8pSU_~_-RW~X49pjSv-U{A+a3taEZVT)=Q0KluN8!@;3nt zCe&MCyH#&EHlL_GLP6hb8<*4#!!GE+){YJ4HQ*hvBlHe*eW_oT6Bw*0KmL}7ZIdOL zrbEZmE^#u2WAWDc?`dtcgL%O)p6=}MU_<{gC!rZpCgcZ}pD|Fgi;A^crR+q8<$DjR zC?o8q-B7m${-FVm54|xiFJ8#?P3@lXLH=CJ+ucR7AXQ3tH~woX(A_?1C}^_(D{QlJ zjdFvm!qWs?J5JKic;lO!KWo?$dPuuWmFF2N_WE0-=P9%69P{+<6vIF4xO9Hs={)~4 z6(a$mapB4tC74n=2+i_h$)GKBe`);sgt>;8@ zbRLNzIm*QBM5W6_@rX7BHE>r@2}Gwxqk!OG!W9?=FK~I#ZGtM?3AJIoyOG)gr~Q zz}?loyvUa2Lbrb5of`&A^ZCDB9sLb?wz@kb7h?9Rl9H1D6*;|czgQqdZz03~s{qNq z?t|$7E1J(IT;6+&9@lGB5K`>1#%qK3x(Z^Ne=Om>fq6&H+L8YqV`7}2eHJ<`ixRIB zm#py2k*z7us$Xzk`Oe)1=Ryvh^Q*G{nCo4#+ zxrc!SN!_v2=UFVrlgN@jkmtu|H(}`P$CiN}Di&E~v{_}z&C>rP>%ah(T`i*ee;oIZ zy|BW$J|#R>p-{Z{UQSl>kIHxUAHKEu;?iRW+>obbJ!`T6+|FpJDk z%qSNXE;L@W<+M=q_ScUD%vE=?6UUCunm4`Yo#oRmX8za3327M_-*p6ia#wZ#e%N+| zcEiBxlgBUd_U=x|tV`chuG|s^d*twNd*9KI?Km~G6*5cES}di&{l=@{udc*k@V+0M z1!PhfvR2%X8L`LOFreCdJ!#xqYmX-FI3k)@{l6*9kiZW-!zQBfOkTvP@J+F0UuaT~ zS6a=V)?K{F=f7-c=6A~&g2>|-8=|`sbR+;<81;Ro`AC-oCueJN!aF-VM^XMKhLbb& z$LPlDuXeVz?WZT;GYa%5ul*yh`Ubf_iaJapy1Ykl zxv}Bu`Gp^6<$iCt{ZB);|5q29zPfxsw<`(#-}Sl(eOOM&`p~YA8%_y@t}aep1fY6W zla8?N8SI(ZI%#whwQLgSBnCqD)=g8yLp0e_&Y)xTo>;|QaP)Mg|q-DqyYS+(%y)rUFD?XQ>PHg&rWZ}!PObRN}M@Dm?d zf-3=PjdE{eFvi^Mf?>j5~ zS~vUVzW*e+xX{G`mC~G0BneEpdBdQLQ!+D9S`hs#|J~?ib6MJqC-0I2ctrTGt(i~> z%&!;L5c5jgvxhb!j6^d}b7<&1l#em#jQbf@;K+&bR|XZ~kkJUB!YcyNVCAp?mNwl; zd%m{aWlYcZSDowyKQK@8>ALH*VV*|}GioDYsF=gL%J)MTpy3xUt|bCkXMw!k`Roqn ztT|BZAr0`zZM9lQ|Mbs4EnT{F^}c=kT2kls?0D=k#lu?CEpunl5v!m@A%4urt0&GY>p6S&IR!X-mIY=7 zl11+wZ^<~l4=j@pG!APIyIJw5t*vdA-6~Kvad=S{LsPRw>4Ce^;I=$?^Qo3Q>^_8r zt!4zYi;EL(bTOlUWV+=@1u<&E?(rRPtXhf1|M+cqW4v~iS*AEHJTtzaT zaUaHvxDO*#tvP~a>`orQZv%*!dU3zi%8q9zChNnIk&!wer2qNhUzUDm%hp<38(QP% z+B4hie|o-QMRCz%=7SIB&V}8?+VeZHCo|>zz~aq0wFJD zepWlRd2!~Sxs#?WnvyD69=BDkrCj??hFYw9_UP7c4P3kCtDsXgq6r4}?5ROpgEGD3 z&K=M`3>n@-(@&lZKMOTem1fbrz%)<J>jXIwjQ%f`|eNS}43#DV-xUCs1Ukt+%^ zxN2?`5k|b9ewa1?;ejieBZ~i%_kNdRF;H? z7Kv5VOwMe|S2aaFJ9D;t(Rs^_w$hKd<>JN(wNv!#j-Qq<;pWQL25yhuJ+m&ByS2Jk zedPjrEIrY{^;2v`PPQ`2LMSKyBui3FeYeoc`}l5ZrMXO1FN>UXV!J*01EE}=ruWU1 zEtf8_ys`Yu)5kAI&5%dx2^wF%@77?(`8m+EKEPhWkx-xxmj+>Nm6IAl*)hwv%cSF>lc(pibUbW*zkF?XeHWUW2lqD~WtiFk1sLAj{ zckbI~GYJs<@q+YVY<9^{Gc?0p#ER4$Fu4e-HLp2E)yik7He6oz1?CYw@~vqWac?C(2!R z150s*=4r>!YG?Zq^@&PKkdM2=^s}pOcUhk@pYn#)4Mi;=fO6YbBX=>1)!Na~qy6~pyPbD_k1ygZ z=GJmWDPb)h^wEr^t7DdL^JwCv?2ixTczeuybIA_H)_`r|3hymYva~Kj5Ar3VtZi=n-DH!Zf!I>gs_yw-ZJ z>Fi6c(nBL%A*~3H^pfhNB~r+S)rjH~zQgl~(=Tg0|B%zngcvqL<*rBq_T@~yc&jW{ z-E-FQRfZN&TRaq93KU0=GKMaXU%Ff_b*Am!T~{pc>?A|~p|$|j9))SbeNd;R6p^4d zB9(zQ6nUmSpj*aRT-(OHLX9@GJYzIEdRSV6@mRmp8!tua3vWdot?q$rhG6M{mCmrT z#mLGQs8dw=vz&fpXlRI93U7yi^r9dLV$1UREoZBNrcd_F|Tjm(>lD=3wE#;uk@;AySTNI`-#IBU$EI=DmK7Ic!%{Qsx&g z#gXDN@ml^OQ;FwV9+fTaPE^%pp^mrAQPpZm5!S!>ymOPKB#uAVJ0S$iy%5IAd>N6= zd#8yfNe!(|X@CvVe5u;&Qh8>JWH%|J)|l4^dnAP|4m9pC3F{}V%CHB~pPS_h-Msz2 z%j?eydWKTi)yQ&8KjRoBmmn|dM!@f4)Z_ud2Aqu1B=`Jq7VD=lrJjFycWx{_!~e%R z{SQTjd;hGOQpXHwiKc~UU$qkmTfAtz2rFZagXkObu(!CB*=NJOB}&1M)a1$im_7HG zbx@I|qi_L%5r-^r!B-x$8ND%kzw9dZG}^X zi|3|5x&DCH7mEv*e0mo;!R8T8y`CcN2X@_v759*_wzlR^ai%zquA$#(I!rzPZg4&) zxbxxe-7PKcQ(d{^$#6Jvv_rl)R%1R>)3-mLphj0J;7vW!! zY-h{?)nntXgZKAvhgFa9)+ifezEmlS3@EKckGp@dg;~T9-sH`IGqRgh3xer`2>%QG zH$&$n+;9U^z`w%(sCNGUfA}}PE+#a0cq6a(_i)(hD;VqHzGDi;0rLQ07HmlY_%E@7 zVj0~n00#F^`3&&~4i`Vdz$UW6giL%%Q}%?Wk}cYM{tf^93*xH*n<73FxVPXu#lO3V zI{2hZ3HTRfF+_~nEZI@x8@fB7-QZpzmAN1Sb(MlUh6AUCf=qdf1e&NhC>Uj%NY|U* z02Uqp0r&hE_)#hSsuK!6{_bj>7)LS^itolM#$Yko$9Zu#h`5Rd+Z8L+U_ zKna<;#Zq3#b8G*7{r>-mvO$}%Fl7l(?NnR)xX3>ikif*ZOw@4MyUj-gq^hLBu(D;F z#E=j=i!e_iV7gMwWRemv4A@vsi7FKkhETVxUGKrY_AmIX-wfX=f*D$b_xEwoaY2I-CLeZjdb!tlTmA#_ zV&y`LxFR4s-`&SS#C$ZQN>-Cv-9@^le+oVW>|i}qW^fj8CirE#$gc+ga{?9wCqmW^ z0y~6`x(vZ312DJ4zYhO^``?BZi)K3{N9}fIC%F(_yr_`)(rMrqcU{w5rw*CC19o!rG-PtE2tlkDTa;54|$CyrPpyepS zppMBAnkY#JW$+-5L6v=@*$z^YsZm3xB9vzRGAQYQhx#Zu$UFK7KkY+8v_i)Gz(hs71uKm8ph@FrI~oMFhAQ(U7zEzY z%9L?$2z5)wZH8j6qbia{f^U^t6+!WZDQ}jwvJFWBI5RB|z!bF)_xE%jV>6>mz<#Gi zqADvv#dfK)I#TtRZb%44Qf>f}K6F$R{?`;^+0-3R*71!7DoAMC=9$$E`rkK=0-NU+ z@(^TuR={Ugz-L#KX4($^iVp21W?ll87z&04mlfSLqNg{`x6d=Jsyr%rg*W#_HAR;4 zx6i6BpcZx^Rw+~(%W8_cx0;Dr+8gL4*v<4L8Wxdgn@$o_DapZ!g7~-Z?%|;P$JE%L zJR7ODxNLDApS8T=u(#1OwhF~^&HlHH-^jx|dpm=D7;DrL&gUzZcR^YqCHhAU78nAG^JOI7~3a9!u1@9d!CApFxASh+D^isau)nE0Z)4G32kPTx;c zx$^?z5?118eh5Kb$q}J$0Sh^%=u~UlBOqGGVO|7||wU1>OVq z_i+$%--K&a{tE!ZAkPf9s}bVCjSN>8POmdOQS=?pAb z(C{k}Z+kqJ1UN+Hz5+p12C;egPr^aKLHOu9`?^o~3C&lh0FH1azBr(fdwLd10M8E+ zU*Hd=Ft2)pK}h?#W{#C}EY}!j<%~2#@ZuYlw*WFdLFWTl(up#O#X;ZQ(tZSd1us)t zj1q!xJORAFOr@>BRX9;Z5@Mv1q*CkBcZplG_I8R(YzGtxwUBYl;o0Prcb z5;AaI;U1W1Ify&^C_1k2pMWh|(4|cKe0&GszexvwcXt*41^)r@@L!<(@>U`b-`(B9zXksR z_z(XL`S1Vb3vjA(o%qN*_{cwZY4}m!@E@T37st#IkVe5Cckz&Q5OUw5{5SuB9r};K zi{HgT(|$Yk4M(Zbi2PK7aBsBz{oSbp22@y&@s0Oq1{V3Az+4Gw(PlJz-!S}F@9yo0 z{`?n~2FAKzQHo;#4^FFnn76~s_KL2v+Xh<}Bxf%81~&fg-4VONR0LefRD-b-O`r4GV=2$E)S4;jRR7l9YQjDx$UHXX)!Jpt?qJIU#u{1QsG;LCk* z;obW@m;FzA`d3nIT~&kTACBKHBrnr>0>cc}PZ2^5Gp5-CXS{B#}NGdUu>=!9Utt1XC?0Dvz4^!lCY zcrV1Qj`bBS`?uhCs!VUVE0KW~{RCV? z_37XKTb<5%UO*k}{*j=!JOAs)eG}7HPB_8RDFQS5O714Y4zdpZ?%fjz%FppBRIwl@ z`Jd+9b?M&#g1}2Z%l9$>@0aI)`2{=6_2Go6+mk}z7JtcJg*~79e|HfFRxJ5|d`Wg6 zbW>;luTJH10us5(;+;f+?h!etSRzKnySqemnQ~cM}daUtkVMrXB>V zllnngjsQTAQ@pQ|@OP>nSJ)&KsPwXMktA>$FSIeN?!z<1~#NslHe?d{rp$%>H?$28nk1v1YuW@1K0z|3@cHz zM#;Vnm+yA{{oU8#M(n@%R*O;i1+o6u?7#R? zvxS^8oLO58H-PPM8H5V0puX~Nfn{i75q=Ugzq`AI9iS<2BM_DaqsIOOZvq*E8^E+# zU`F~nP0Z=6MtAS-?&7WmVidq2@F-+bV89qK0rKL&p^}(}5N35OZ9m{Y@9yIu?(BiO z55|7}?i27|ySu3W_xE!jyStA32mA;Bzq^M0{oUQ&-83J+yMp|u{b073+q=0B-_=3L zfA|mG-NixMf8X7}e(tDSWDWh?SfAyAySOx8yO{m`+(?eEiMzXmgSP*_s{E**|CWQg zgN}oigTJi*xP!m6{oGU?+@u^d9r&BMh&bp!AqRdY?(UEqx`T%O_?x<@JMlMipMZnA zqz308{r%nD-838r;lF=(cS-wbI4|Gb&q3YKLCHbi-O)ka)w&rGJK$d}ImrB(q@?H!}Qbi|iw~}}SAH)-s z)Zzw_%i#!Uq?$FYW+7#o#K@89shueXEJ0M5X3Jm+;dYQt#Dq^0Q;51K_0`&n)R&Vz z(hV9~x<-;y21z0}m0+Z=Jw#+;CKu#n8Yw}ILk-e}kfa`Le>c&*W_MFt)iPe$W~A|G z?VQJ1y+*M72-2LgHU`-hlVq%$9Nz*GBQJF?7@Zn~Krbd(iNbGGr zwVu&>#^|)ZzJgo?No|hMLXwXxmSqk?^^>p@)*HZ%!AhrkVkaBBYTA^xoaXYTIXz|R z)fpXWold^;?q}O-I*T0FxYqopN92DjvOY+DN`X4Ajf)h_ig5Ne`AG*XI+d;Er0>yL zfJWTZnTXZjf9vht+qD^uv9z??-4+!ZRpDREH0PA1X$+#2K_*o#^HL#cmLS7gmP=76 z0Edaf9G6f3cvYF5`oD;AZ<-bznT z|J`Y~qR*wpi7HyPXzUBlPZPz787!DKQ3qdry@PS+4?qRXsnJ(onnb4z!%)Z^+%C$s z?ThCJTy1hSzth23PNkdf0TUW>2scesHH|7s(*}qtGQnD$c18^+$bqy5kE+H@BOX^( zRe6W(NU-ou12$6(a!X(hd$_3-th0zoOqK>Gq5Pz_l$KTWuz!hfu20QRu-&^l$$k2! zA6$nFbxNc>tkqq(bDJ+iz}^x|7^vE4*;I}6R?%^2)`rX-frj+HQRimNK6&Joh6CND zJ0g+FoaM?cM|}0=!JP6j4;l4gqe`X97=?sNq>|x-6eKOv;FGwMw(dyBBQ&yz+A)q9 z;D8^0rXD28qGT_Fp3t@iawABrxcqUwevAD-4M z<~?-;!nR{vf$owNgFGZ8*7iL4k5^k>jqDF^+tvJTEVwYYF{}(NoAc0|4sEofe9VZ_ zMOh?6A>jRw@H|owpJWTFY?l%NJVMn$*fjL0Jq9j92ODtIIk|fbVs1Q)wZ)Bc z;I61@iAfn{w6TmgmNn1%`K(^GEtnS>mChHwsA$nZSKM{z;@-pWKLr&G!6Ap~PA zQ4;burgzy1f|Gv{Dg5b-rUbFn-_F0^Q8w~;5G+1?V9U;>vzq6wEw0ciazE}4%<9Hs z_t%+-0J73lh7a*3E|ZBO#7V0ao{YfIbgN`uX1*V(6xq#Pufk=mS`n2*Z$F1D#0`rx zq%EFhxspHxnKVrz2S@=@bTQ^oc^+WL3v!aPjxw5z zqAE4;qM~NsKwfw)b4Hm`4ovuF?d~~~*)O5!Sk|J<$TLDg|iGDHH z18Sxf8X^bEf~=;@Vv`ioay!4tB{%&~%@a9G=#jRi;oM37qB>NrD7-SDQH=bI_&&~)`xLs+cf%=}br$?}w>!`S zG^MSAohEFO6m^t6Uo`VT#~*J^j(?&0J|&TP=7gK%NbfBg&|~%Cke4%CM9MG>6i3@Z zX*e?gOx!_c%~0HwmIPli$xve(h>FDq2yTZA(_QWaiEY#hX5yvWuy*c)d^v9;t+Ow) z5Q9!+7fdA~^*9!J!~;?iQ5uklTg<0Mtqa`m zuUIQAGdPS?9b-q2BIpfn*~I!tfcjc6hy<7>Bdz z??$tKR-pyvf;ZudKmP|u+~+Xu`3 zb>Z1ij$Yha9IedF&C5d;_Q17-p$XMOLL~P-=w3}w*bq7!i<>+Rgo)>ScT;{0odwyB9>QZ-b^s3UO?@gFJ z`_|dY2Dc!ivJkEiAX-F7g2^l_*Kq0N<*L_4gG?#y$tN zEl>t2Gy=1|(r;&a<5ARQ^55{DBV)yn|NeDq!SxDbZcfd@WkE|-FpWbcH>xG30Wv0l zIqs7z)b3@&`1khIMyeY|?c2QAbUNp!QmI=~r~h^)`%vwfyn_24Ktg5g8qCxP>fZbx z|GXM{m^Kq^K*I+s<8`${_wyC1_|)1Q_0@up0e+Knkx=eNWIZibypqb zhGX92uWj8$G47b~vC^>cOrYM<&Yj!7{o&jG{K1JY8fxd&j+yIIoRCAa2I?txz)(*3 zA1ZGOcQ|xKBW5rNg3I7C1{t(E{2RcdcdOACez; z8qGEL&Sf;YVq)=(jSERRccfOLR9Nh+k1l{LrxK9k0atsYGtoZfv(o+(6cc&(gdY2& znrvqwFz%wAv(HRwZCbZ_ZF!p0nRX;G5PE&@gbWou2aV@2qGr>f@>tH2G78Jpx?XK~ zXUFk+On8;>K8`4^^6zI->YwQ&cv&m zM7!cEeCEQY!p}#kSC7vQ%&(jOP4%+Rc2KY~XKu%)Cn{OP&Yy4o!ym+On)IWb0N$eV zHA|ka+O@hb)4eg7!wi@Jn2JF|LEwrsbb1`|Z5yGS%Ur9VU8jZ;Yz6GhhscvegF-ux zen?13ma~f#vEnbEe&OtIp1d@#aa5cxfRNp)X}Tt3p=A2h%V%3ULh0{b$V92}_RWf3 zB`QEPGrl3qw|T{9e>$I*ch}13)S`s;NMfJSvDMP|ubH?xH(Xq)I(*D$QqF3#V@XHl zyoG(Eo(gVxC^)9$?#Bb>j5*7Lo0ko20`3?>`3*70t}L^pgL{QEj8^)CD<=)969w*UDsQji5jg_AIv1 z(C1m4G}zP4fxkk76o|l#lzMx~I?{c@pu1YRzoBb zIk0EX{_*GLjGXH_IH|2+d5z!UHkpUREJVWEU?l8{O!aOGBAX)xC?mjE+S>@IitJ#+ zZFmD-j8=S}np}nws8nw|3U`IRB;xuiQbHq^YdWGP1Mu`o_c*qsdv)y9xzGP2e7i%8 ztE<-7_Nkkc)s&|e_Srm)Se&R1?BGuBkWPJBhb-Tr&nw0xw9AUhnAs$2_`OutT(c{9 z>)u1}PCa~Xg4W(!DoE}dH8aGty8XGixd-Z!&er0Pk=;R7LP63Wq_qEdq(V*rjx?Q@97Yo<0Yq+$PC5so^-{zv?6 zym53*temS!+JqHR3X1Z8LM!?W`bhVr(IN>nH|nrn_iZf;zl~m-HfH}0;ajvVQCEC% zTWNk*1K;*!&kZi4@aWN7uW>LL<+eZxqgguRhx8R?>uNjgxMqTM-Pz|c?FR*Q2(}SS zB`$$0eiDVi&fa#AKYDfE_l#nM`-C8nvkW#IY{!}gtL(X5@w#2{IXz3#tDmoV9@DIW zf$#pU3b5rOY**s0zEV5qNFg?Av$}f{=6ee^y*HJt(*64*C$~QRR{oU*k*dhZC0T!6 zoMFPDh?~roVJIWydr2wWIQEgM=t7W_xCX52zT>KD;C&s6DDo{lALi9)2qhW~6=9kN zXotH>m46^Saks*-7)!KkUq9EFsmyI#w66MhN>aJiaCi@3m=Fvlgeb8+)m4Z#DRXc@ ziGTdtSNaZ9Z(?0@VQoX@Q?IYurloJma!{zVb#w83H96a2r8|#pU7ogkbr1O~TQ2S; zSu2AZj!Rmu>iv-5@Ao+&vI6;H{2?}%&BHzi4<`lQ_03`$&pd?{|2 zCTnre5u)TL3B8D!#8q3vzw783hWiFI3ma_txxE7FPo>1+1B^3?}e= zBTT_sKSX9OaHiXV9W;?R0fPr%k~B9+19O=jd*CI}%-9_+$7~^_Z4XAG*ltfqR1K>M z^zD{gXH3+f17(Wg5m>3gxKbky4rVr3$%2(cvF0(e3`e%E()v`IDs?++wtM|qAWWyT zN=xr^b%C%9S;PC;>9!RV6k$-Z2?=a-6soOlXVWVnwJo^@%2P;3qLsZw^KqTSs65r% z+l#Y{$B34ODWKWP5Ve8AuACQhy4wS@7H>G){fsy8%&K)CJo=-mvdfTd_|HhQD;NnP zMSwaKz2x+oh};mFPr`u}j!=^5g-8s9p8Md(^-l$-%nU5Z8%>etpf%8ObuN_(1*-mg zdE}oJtL~YL=P~RZoInyrcKa|RDS;?_0o@?vQtQT1G}Ik%%Sc2miFPK?+_y`(l2g~| zq}eoLZPnU9<1Zh|Lu;87AeBE0fpTQY%LVCM*&zGN4{Pj>l4P{;YRUl;#&MzO6p4hY zjf4<>ZfS6cP_@W)IF1r+j2hZeByttKV!C}O(aKvuiC2+vHW1+T z!)+}q-5%OG?z?+$UUOW_=R`Sz^sJcdl3)#%@%V-KcmTcj8qM))2Gv20P~u0 z(_+<_>sF4E77S96Va4P@3}j6ilr@mIIx{J|kR55DEE~Ti2s)i0GUcGq# zrStdy^L%Pl(fmkr-VC*fS}?0)D=fj12yq1<2t1G}meU=oWX6j3txT0x_^Q*VxTYA@ z^*)^|RF@XIw$s>pf!yiN&OxQ-&3CWs)zcr|(F{jy5%aNLzp|c^LCseavWc4gs%v-L z+x@}3RdU-27yQCRq&pS3S&`@Pm1*=?jmCPfAS5oWoJ(}e%sZi+p95RPA{Q>HSXU@} zhz-$q^fT%hy_Z7dDrtVKZzz3lz!1yx{mJOjJO?F9@*N@oU(eVCU;N^YH@3S2c6_~!-tDD2b(7@V_XUNyYoA{)Xd-a z3c=mLvi+yrreNPCT_QqiLqw-s1h&~oO5l>dHQuLd;n4_EUYCOyat+a+$PL;n2>N}B++COt-Se&$T-ZMYKbgWSQ!Gv&WNmiPexDgeWB9cNJMdd6QL{j z9N_)jC(*Lwm32S4Ek01A^>o|^-3TWAT zTy$eJgkQ5?H-u0)M_a#eUWzg&?jXwCzLV5({Yb05H-3JmbYu}#<>%gmifeaY)FdNhY;GvD!#`ld%wYC9m~6e6sx*L1byK1?CF!Cxf(DETj0Zp~RVd zPH2EGdsK4RdoOtcp|5At^gugW8~`=EfcN1hxe-xTtti6C-8uo!%RCz98#;<6_QA$U z3>g~*hM7wqgX{p+QiXs;{dn32)GqW2dmy$D3k^W^ZOBO~ajK_`Eq}c``^b z?N%U-T6N-r5m>~BrbWSM&we+d=IPWr%`7XkPq46-X*dVRx!M)qZQ0XKKaj0D!5l_J zCy+pC{QAbB>}f<+GeyCYFl2ZUE!8VNae_&M8YbTblLm}M+HkTr4LPJTAWEBFh z9&Wg(qQX=-mJ+HC(mT%fq5?PO5hIiDg|>Fi^f{NMbjD}S+0VFDp{C?U{HhGvebi9OAOC!!w zlSjx;Qj^te`kt=3Q!0yJdB;Q%@t3FGX@#N9Ve*O_-_6L>^;f=4T^&ks&=>SW3N#0x z!skONs=)crm3$1Kx%s@p?l&Yxzmo1$w*6b7#<3z73O`0E9h7iP4)@~_?t7q)8=NSM zo}OUPv&7@EGsjRW9>-Szz`KYkbTw6hZ@%*Ixd`9(khK}(SpV&?a#sdZUxFxo4Ufcv H!PNf)C2eqQ literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame04_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame04_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..1dd8fc0d4e3dcf2e5d8ea91790933837ca2a9c23 GIT binary patch literal 2745 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame04_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame04_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..aa39c291a28225a482eacaefa5abe7bf6c43b30f GIT binary patch literal 6540 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 QO>GKFRv!&4Dh!PN517I8g8%>k literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame06_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame06_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..75c04fe224ea0199924f066b1a5db473ccda30e1 GIT binary patch literal 2461 zcmV;O31ar$!K^D6|N6rQ{3(+Vuw!@N*t@{7g%Mb(yc~*}1g{QO1RMxBCy=;79x2y> z3IjqWNlO-5$?m|LP!adozu3P5#rPI4$*etM{;=QO!mKM7{;e z-+%Z~-R0IF@9yE&6^gL<_jW!7<0FPi2MPChG^rZFS%LMYEWjrts{PPYL z3KEK=B^y$+<0jtxE8lzXz>JRHg%5V6lNND!CNMGAHW5)Eo|Y%3T13-B-ah-(2)J^kG13x{`s zcSsk>!vgL27kA)HQh51*5D;)r5BO*JN0?TNUP5iFQ3WT)Xu!Yx3*UW<{vn~70k|mD zs{Q@k;xmbDtFsgEABBH`sXc(FvbEz|Szw+k_GaKh_z|cRn zzQymv15G+9#_>0|5+RMBm-ch?f&0V(%9B-uwkxa29}#d?zhl zjx?|noV6h+xP%<;m@t3*Yx|TYXxI-bLbHV*iAPcqp7z5)^?$2D)vV`?>0Z zC+cJb^$k=g{ENiwm580gVZo?I8G{D`5BLyx< zQ-Og<3Way~b6q_UK|@C&jbc`(yTZNrPj)Be(@hu@o~`8DCi{PPK`F|zXuyCB?nB^JJ5?57S$Yd(K-w(5pn8gOoaJ;B5Cy(9PCgbK*a=6aE^H8KUUm0hddb)o&_`1`agenLLUnf zh5#R=N+cQ_XK3+Dg*2>*xrJApY~vhIg?bC{+4UIIWUpv`PRByqsBHtX9hP$HHaE^I zv|{P5rbl2-1fElEgBQQMr0`q7N|xNVxkh5lTGC#Pq8*8y6r52d7lLI&hSDXW*&9r4 zKQ~w!k`2%QS7mn3>0CKF7muh%EayAxW+RDLC8G#+ivhP^?&^ zDiYD8f+r_S0Wy%r1WoCM_jl#L%7^KpYJz&L;luwOWT&B48(7-XypfRUoOAwy+O2@!Jd?8 z(I^7w8YEu+?$O3vA@E;E)BzB{GzF0p38^uuHK{mMYyy$Ow)2ESq|Hh$5iSu*lEBzB zS%CvY-`(02Dx_Qk8T6gN3z5d~X6*ps7P^VTF~T(-)ToEpDE69%2<1u~Bitk^MlJ)G zQX_wNXvFRDoIaP z_je~Z21ku7)&iKO?H-l2z?oPRDHMr(1U|qgGjNwauqH`Y)suA2NLtJP-`(4Ga%#)@ zr4{9MHABUT@WDyB4TCWfV*wnH!^FJFEQ*X75m|6+EQpKW-QBFRJy}gHZ3-wvpkd$~ zX}N%VNw9!Nh-w1{)hmMrvhVKg#(QjqCj!(FfWMDT%qs8=&0fF(uVB=VE2-ZKy?{My zAb2QM!GllM{_fgAA{cg|ID|F??}%H$0WGyIN|3iZ;7<5WORF+M<$)t%Kee?oH}`hT zYfzy?c8rn-uDCO~1^>XE%Xo7C!?~uX2{f?*2!X=Fk_#bq-`(4bz$BjuDJ%%d4Q^~w z-VFhPUYFB!L4KBH@QyLkNdEy5S5#m{@9yqgP|OGe{0ocVkXECCJEf8DKo$=rk_&_g zgvn-KhI-w>xUeDS1HJv-!pLR>GYU8D31ou9A*STry7C%o!XSyTV|WDW8HXQ#cXXW- zd~yp2d*F`{ST>?k|@owRm1lR=FM8p;ad(Fs`b|DR3xvBpq#3sO0 z0v$)+-Q9i(`zF99z$U7JY5s6V7! literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame06_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame06_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..4a620e203ffb7daf9c330f99ebaa2f8811563bf3 GIT binary patch literal 5734 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 M0HL!wpiQFx2mV)atpET3 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame09_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame09_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..e8d10cf69f7febb5bf03d575dd92841c351d5f90 GIT binary patch literal 2565 zcmV+g3i|ck!LO?)|N8p`q+fm!WDN~$U|(YrJyr_oP-XY4!>VpVa4i^6uxLU50u!Vh z0+%F!A0^4$n?ynOFR^|F_z$sw`|-v&z^}bz{=VPc!mq0*{=UPzi+{nbx+K7k{2Lf& zI9E-?xqb5R^KB&H5O!E6m??lp@F9E$NF+iq2 zg+E|N2M(tYlMQezUEgB-CHL#_7vO+yCTqkf7X{K6z&yY_#DbC}p*~qOpE*)N zzxxldeT*CYe7gJn{oFeGvRB^{CJXFc;87yFit4`m7kCyhX;R{7G2$RblKdop2MQO! zJop6IFt7;~b)=$LqaQ1-i*E1uHZTA8&3A9Yi{IVEuf9v}={XI3)q@N0LiX5Uega~z z!&hJcFT`Jp8q5byf5C)*{|*qyE5vGJma_3Bb=TuN!@d6h--z`>B*nkGnw1l#4Van2)~(`@9SAChGM%afP{G zo)0j&YB_-m3DC7o6u>(QSA?$w2wpI}WqyM3tNso4AAiMqnjisQ7@$MD`?;@PHl*$V z?xWTv@4J2Xcor9qFVSD5zf9>PC6JcygYeHG7PYem76Sc6`?O#YU zzhlZ+FwmLFnz2+wS?FlvV(zSIb@om?gj#sa_IP1{6epyR>m9wm9PVxQ8dnP?&n`u zCq|4Hm8VjZFxMivw;322VhnA0WsxTW32O$nVY{Jo$*zUZ90T+eWstxZg7VZgg?<~d z!kMC5Rs>CrDpV#W1Pb5X&vu=Q61Y{A7orPLJlb>S;pVp&_5$Bk?3WyQ4tc$F`|44Krbbe_f*E{_Ta0@hd8+<7o?QueNfV(J)mtmhE&jaae=R+|s=9T*MwfyVP z7xYe8%y#J`II%0Ju(BABBI!1Yq&Z8=s)@h5ph_&j2mBLUt7lb9A?QuwJg|GRymL)hPGl3-0PN3;J zgiXU{ZvO7FD$IgF;7PT`e6xY_W)=0p6=O7NAVoq(wa3TI0rCZ3^-8+pHOvHFG&qk< zsh%UIH9_YCQ!!B^(|>nfsdVXJ1wHtuQJHkVqJ$)_u33J-P+*w$_rV`g8vnHhQZyOS z6#d8e55b5Lc9;W2j2NGPcU=S{a46w{5!gD)g(eea7J;I=BDy2+--z$w1-Am~qabQ9 zk`kvQq%hKAFMoGrvG@^WlZ3;lp9Vh2iTDv8zq`A;i4%W!W$`m(@9ylo$&ZZv+*-ST zcXt{-1MwfbhAIH4BB5{Y?&QA$J_LQ-MLo_BZ7u!X+*tej`@6WY9o_$fV+6i@SpC_H ze|L8cJ~dm!n2CWHt&j%UZ7p5E6%3q#RD_mfl+=o@C~*{sjNo$uV}Eyd1~Zmc)eat* zF-4nWi;Iht=a?xVehfCwOr%sXKsxcfY&461T{n1c)LAaCah99|GP7DldwX zfvA1$OzLrW5RF8>1h>l!<_v|wnBU#qS=WIhcuz4!K=l)-WK`IQt$A6l3~xVkvH zolgRQqu}mnaAa=+vhVKhM8pEVVu_Mq91BxElz$1t%A?H6Nu>UL1mOBO>{iu;NX}qZ zTpG&*Z|?30u+ekG0+Ju?yKo(Ny9{28q+nG|VPCU5TTj`Z7t&ABjA_Du&Jz`3RLv-xlQ02~>9 zcOa6_E!@B&x}y@6O9`YU2vf;@{oS|Qgp+`TpAg#0$ZKA?rTN@KB(gzE_y8m@4gqX} z^SFdk{me;1QT;z@Bd{*nq#pk6<;5GofFXp4GoLUH6ECoui7p|US%F|OK3I!@AcVFM zDP%$^d?S)r5X>4G8wgGP-Pqsc*j9%KL|y&e+?bd%0Mr;0TEMps{_gG*@?rzF b{{H^{`}g}IlTlG)9YgrC-vWCEg36=50`j&w; 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 F{{dwSX43!w literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame10_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame10_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..5cf889061f1ebe14f74511adc2145aa73b1c4cce GIT binary patch literal 2477 zcmV;e2~zgm!K^D6|N6rQj`wfB1bGh8C5PYfBIRhQ;k8uJQ*8>8z=MK$@Mwp@O^g)7 zK2WzFg|4$|0$^YNv3>*o1@FfeivqCqi~7TVcM7nqU;4uh{{EP^Un-zzz>jxh5$pdd zHedR``G5IcB(Nae3={qgHwyv^JAmkX=*`DSLB2qpc*g(x@Ay~b4`9|6i`E~1cM7nq zU)C75J>CI)h$ z=ZE)5GWWoP2p&>Vr#GUq1P47|$<4n7_y1!5fbR|71Wo@%#^Weh5weyP-X7Sl3@_^}chkwAn#tIH0$|~4L zoBO$;i^6?}-*s@;47yQw;9bCyIm}ayK&EL4g+gcSOWkoJM2FE z0}lK+h-y%Zd8u!|yPQvOjYk6S0z6j|uy4S>n3w~GWkFMUfI%BA2*5n^sCk163Kj&s zW1%!GwQf>^Bj50E|N9t;g(<~{DV1D1`?=Jb#j$Y%LGu<^>;h9|j950!}dFL4P-`&ngN-F^T(@BYc zeg6XYoY{bCz(L~&D|ese><36VE}jdgg$DtEaYm;0qGbd>4)@?+ithkh^xFe*szl%2 z&WK)Zjw6lSJ>p&8V+N1d0rVJnV1V9^LZG-#kYPG_I9Eao0Q;0576KKY4*M7n__KnL zp?P3ao>sg2xzQ~&t1ZxSEC~3|!n|nO3|80|JP+X6LI3}SXoSE;G^XwH3@t^OwU}r<%z)qHOrL~MmKj2w@4S@t&0n>pM zEFDV@|6>x6SP;Djf1!Bfi>*(uGqLjiNR#VrLU zK44jXI1W{Q1if?cEQbi)BtYmRU@;ekf5b#vsev?LmWaZU;%8Nx`?=3Cjba?l2Dx!y zzaCLarBqoY_KtPD0{e6R1if?rm#%%53Oh*?UXq3bHyXlyj2VC}t=%$yra};2WR(k( z{r%ku&yy6;sl07El>bg1Jxj^sq(Y4cAPPEQ8xVb!LMKR-YvJ||q1zS!xUKtd>y`-R zy-KD7R~pB*y{+4EJF`Jl$uFeMOfL8LbRqKs*@#&~2XqH8zGFq;OFZeZN-Na8aY?yG zq*~Xl@l#r;3i)6rnJxmBpb_7fa!&yc@mGx4{x)5s6 zKN(brae*{o*7ZVFX8!J!!B5Q08>vu98NhxWR_5kQ5`u6Hd;kc*m4PrrFef{!0)ria zN~_EYlQkEEf|`L#5P)FAs6P^bQ?MjKM7M<>xN^0cThz1sZ9K^klHVUt|7RF z_(8~#fUT7nsDeu9%5BW%1!~tSEPa&mg^Isj6D%oa{a{$Yw-B*V$iYX*ofP!jA{ zqGn9I0QBP<9n{SoGSpL4q@xQr_jRh$&ofZADksFmwNwj5Cf2CLrfyv@YIB@c1`G^h zy9w+JtD#znwx2mQzXmS~S=a=HrYYPBhwtv|)zWE`v}TDH9{@1t?ATHSJO+ZMw(_!p z?)VPk@gML~3SYkJumabFUSZ79fE64E@9ykA)nZ^fl=>;6TjTwdHGxo^svVYZ?(Xj4 zzXALEyD>AP@9*yJ;aK0@-OG=GA9oVFH-Lp3`@6WY@Sh)le|L8hBktoD{1@cDF@6KR z{oUL;BZ)B*VlflEHX{=BI{J}e+GCrIWa?2) zxv7$aRwL9Il@8{@u2Y{<$+_GrPM-u08OYB4{oT^ZJ%P*$+Bn_;icXayMrBGQ0>g2v z$bEq_k?*BCITKQDRE&^7(|;3xJ832n+xk@DIQQC9>=S%m5PId;fu6EyM%H45^Ol$0)G)(0*Npk(IR9l z*_WZ7H-b&)7YgA*_wVlR;XeR_z_P#~;h*9|5h`8>oETt05M;tIoR+g;K&}1V+*%oc zp2AxPhAWz#Vl2(2IBoCLlf9YoM!?){_gHe r{shAbz+hJ<&2*Oj?(S>mH~07V_xJbr_xJbr_u_BF-{0Tg-{0Q=8%(@0 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame10_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame10_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..82302d626a8528c41e8a294ab593479f55177ee9 GIT binary patch literal 6020 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame11_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame11_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..0a3ee0210e47fd196511f8f28c4914e2a0d1939d GIT binary patch literal 2587 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 OYdX1!FdikjpZ^C09c?K9 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame12_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame12_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..cfac73e630c69825974849332f1990a404276bfc GIT binary patch literal 2569 zcmV+k3ikEg!5@L2|Ht@mzXBy~&Dtxwv46mR6kSB>=}O&!traMimN+nQ0tqYN17;o2 zz=JcYEt}O48*+Ch*TBF3V*3xj2K;!*fIr}8Ntl ziBbZ(Za*$RN2nsy3kL}CHbJt4LBxc1du2HN(`IeSVr_f>{rA{@4UDn)8OQ&BcL;t4 zem}*hw)?lI#Qoq*n|t5vKE@Si5JQjTX&un#<)wn@f_R`q!OTcWe4|gMo;_}D;@9yD`z`_5-n8AJcAZ<9Q??y0Rh7xW+wI=`wuW|mMTqDdBu0kGQOV(Hr zyyX&bEefUZ52Z!C*y=hhniGi|ffB=w^ zwg&hRoewZiKgEDBZaROiQPAVz-}~==E4ADQL`R$Zxv9^?PECo;qv@>RKL+hQRy&Xj zkQCrj%BoYi!h?W$i5M6Mm`IgzBQz=@TLPho_;=X%;9qx&Y0}*aX8!JNSgD>7!%0_s zRl~F#KMwm8;w>l)$j(!FSEotJm#`)52yS%`59gl`CUqvTt5*OgD8>ce0(qYS6UBOB%WZ+dTutJurG^1g;7(K{`d}V>1Nrgb0RS3Y5Z%4@JX zR%Zb7dWLYJ^=R2`NN;hAK5d-`&i;_t1Hv2qYC6 zJD5~*E0Qvw_aP@CJ72*?NZtjI<&fp9U|C%J2q~hVC0|3%{ebklJX0 z)Cj1{1SJLGKMEn$&Xsh@-N2a#Ck$-k6Zd=jx(Je1lSRT{3Yd-|0P+%yE6y+BB;WSe-())ate2O=fJ&O~XON~F!MiZM+?c?dyTa@32&L08h? z;CzspFcA!`DW2cm)0j`b2su%%TJ)%Vd?3CsM^&`h?WW{PWBZn`0fj)WM8fpB6R0+6 zz__Y|n{~u;^M1J$Nlqadsj#$4wJ=TtNR%)UG>(4$?yRaUwxonpTHPx#B(|ZtNSi!Y zrlr9yve{Bsa_}bEjy0O=n~dC1Rvz$&iexOBjzv=JK>! zS746xUCZ zRuM%i-h(X%-`&?uVlL(#$pGSzVYw3qw1)pq{= z{_gJXC0)O}yNdn&-M}S2n3gy9cWCE`?J-|>n;!VaBfb6I+#%%noElGNFENU_2^5je z8_VQM0M^H_B4I_l-`(B8ligL-P_6Ye1I8=`jYj4cdW)< z>>*jfA&D+<26J*WR@rhvIS5-*L+eurQgTa|V3uGmN}8g zsB5O7=ouWWFFvhIoTz0Kjx%axx+zKgoS#7orz&I3(j`q~g^^#HsP_Ku*^ETwSCQc- zMR`{uq3Qs=5uilXv5PtAoTlYQibUuCMBxj`$ydO|lsn632C}XI-`(4N4v|&BX=b$! zIWb^{r~}-R5ZDFNXax3M@DHNyfG$8_O~eHbz>(||RUY5n+_uP4M4h!nOd=NROTrpS z6uFwK-;W*m2n-27o%QeLtOXShASiYOiovNqi#PXn)8lQF@7jkcq+n)|;usLuu4#YQ zj`NP!i-1b<7VbAK&h_ql zn}lBiU**4Xz0EZM;M4Yx!Vsnm>|XxvII?a(6G2zI#9r$#YgyhJZQYanA*>ogj|E zCcur8tQed7yOGJwmOZH4)$9TYx(4_JF*~mo$=yHpj~l>^;KK^7-u~|H1H20_fphSQ zi*jw6umEri<^b>tS(S9XjbuF^e|L8X9dInLV|X#V1pXIVd#}L~s$QV7^fLg1_(d6q zb=&*9xKZ#W2ZD~nBXG}w48kwi67rx!>wkB58QfZARbG0cMgRvy;m}+I<;8;lB3-v;PX6xhWWv2G!xuYYAn)$( f=05_Sef|CY{r&y@{r&y@{r&ix@i+JP_xJa}rDN&b literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame12_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame12_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..94211a38bfe7a1e019163b16c71fa03678a7c11c GIT binary patch literal 6368 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 Je}8{}e|&nFvMm4r literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame13_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame13_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..16767fe906a90ddd65491fd86fc8655806ff336a GIT binary patch literal 5948 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 K&#h`UCip+f?3?KT literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame15_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame15_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..0cc1db3bb3648441db3d970d201380ec7f00a108 GIT binary patch literal 2598 zcmV+>3fcAD!5vmV|KqGm>qmSF0Erbf-Pd1ti0rOHR&7pL^1V92B^|(pgA3w8{|1GO ziS9X6+6|mt{rBKsfq%fUen{)qKgV19xI?PP{~cmo-yZ_wW3JT>e*W$t>*CGYy9W2*PAk*okZ-&1C^vW%=DBG(X@CXF z>cJht3#23d1Yknp1ez|GWv&7et$JnvS1mx4!@u|6@n1OdoZsEeQ=AwT%+HFkK;Zu6a(y5R3$`j7f)uY?@Z}cZFj9`|vM|!-(~Umj^3d z{oLBIaHbK=iB=y-8Y>eg&g1Gkm`2LAj0EU1>>MLoC|PuweicQzvJ zBDBB{5P2~#yTFc|oCTmDxl30q!Jk9MOjNM29Xu=uPLPO5ZV?Fz%CCU{6xy7(V?G`4 zz`qsQtdWXH19IC4`}?`ng)b>+IxBS+>cCtm@AtkjRU59ia^734pUemXg5Ww}LW1a8 z!m>wjYh}{cXsGNCTkpfY_z%Ak-EgP4B4vQPm_;4^-0D|Q`=oj(<*wxYzEF)-98=XlYz=JIsAUKZMdcrEcoT3UojBEh zrG^OQUg%8*$bqtrj$olxB!|Jvk;{|~!m#`yN>5WqYIjhBdn7VSh0yK8wMcSLt6`Z$5~a3cb~pyN4o9W8?dH%3IshIA{70S(|K@B|&Q<+*hP zHBJs)VGR{(_63@9+`A>Ui!kNeYh)ab*5$jB6{0m7mY)9Zl0XOKsn$lTd>jYjJ96%( z{mZ>W5X0=hNxE4oFHdo|Lma#uyjOV(LgnQ~Ke>5^B8gV?xq2IpH85eqR6+q)0=JRi zF;$!Ux@zVF_65`1aMbZ*%I(Y4+Lh*D!gPb5hR8IOjkh>3q$=k*d(!no6&`~%N-7Gf zSl(1hb~i7Fc$K$J?6A~|BoKry5h#8iJQr`@-P5eb(``Hpr$b?-P@V*QD(PUwD{*Ba zz02uDw1cCh0W|w_)ONv+jJ?a6a1l^c$(g}~*|9BY%keRVd_?J1Q$Co1|3sW8@9yf{ zU>OMaO+}{Vh+>u4X-o~Y&2m#I3GjV_eVFHGqbrB19f1^zv(Zh{s?Li=D~Az3cGpUh zOrZq_2m~T-F~7U3ZR)7dj&LDl@nR*>u2D)GNN^HTZh$%VK2ex=O&Yf+-74l~6r+Qe z>Zmxi0^4$ER$#8x9U}>ZS--olPSr?>K_PdER~~vQXYcOp)xd58!u^FGYOcT*ke|^TxSRXCyST3R z_jW@U6E;4<>A$wO4 zKK}0Et(qnr!MrB)-%T@$l=P-dLpkmAkp5RE{ z6)!jUcMS#v==*vbdaJHsHwHi#5Zw3RSr;Lw1Oe`r#d78p<`x~~0d)Xq4ZI^HRtcOq zHvaC}!GWYHI0lqr=P=5vhmaiiHGkk)7d?+2>&HB=5pY9q!0#|+mYBej+!xkIutI+` z-TmFLIc=N-V*xBk2qVZ03h{6J2+(uMsQ^XPRCCM)KCmun(pM~3;N#O*PB~2!$WQIB zC(ZrcwLnB=LU0oHKuZcyTden9nyza1m&?jo?jQy>oKh=|+L>mom0dQE)rSw&t&SsJ}o3`T)R7=wc%8?(P;w;g}J;D?x6( zb4%bj1yFa*%5N&c=+S^dT+*&Pb{RBp1^fHEs&ZLlmh0aFG$03{VdMnWf~YY|+9PDx zR#CF6i+=v@?0*|ANwF;zP-`(BC43-5|Fv=PJ0wQ5R7$fMUp`QYr6Jg)o-Nk%Jvz=E$BCI!| zh4naQ2LfrrPhs2UyZ-*}?qYKT6*%J3U?c!<-`(BKe8&F%{{H^{{{H^{{{H^{{7v|q I`}_O*;B#y36#xJL literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame15_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame15_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..676b43c241f6b4369b498823e9af213fcacf84ca GIT binary patch literal 6616 zcmX9@3v^V~x&HS)dnPBtBsqC90@giY0s$lUBt{+@b!RexNuWAH0-?35111`y&WjfIu6aOuw}nVx^;UsAr^n@_bKogVS|rcDDDp}RkSff(bs zXQU&3srA+_ip)J!68PQz;XQ4mpRbAB5y{M5T+yA%+$r}~QO(`PajYl)hcawqkmdpJ z1VG|d0P{z4^g%|0=7U#5e*=gGQ6`a(qV9byXkoyj-&`;zLw=lY7Xx_RGOj=GM$l; zPp$v<`KJ78@7~5g^=ygO4Xm0`JUtlAk5#^s)Bjz}Q$BRx$t=1V5H3HKV^OsM0GbTa zGC-F4WB2Hd`Sf}B=nM>S?WQTuq|2a^j+T-YC1#<~BrANxo195Bu_6CHzJ^*wSWXz| zmz3z_82ikHtw$17S5?bhOEVVisowkT=y$L)<4Lj#IkcvjwkNj3y`)9SWMgCDD_7?i zMt--ZmESMKtLbjnLv&0PrW)F7R#r3%eXm}k=ckAUY|p~Dhub}s zi$wHv-R8g;#lHHWu+THrF%<=2A&SvG9rGe^H(nb3s`OOL-51Bhnvl=w)7~W2KcoAT zEB5T!_wC`&-urTNL9%Jb^2K7}*eR{o&hDsf5@6G&tcD2_$d4GJtKTL&13W;2jDrXp zyg5Yeu9I+*F?s0`M3dzq1D(=qQe<-L*aoX%#<bvsa3S#d^#;hKRoq;mp>Z{){t1` zjYf9G0+UP*~X~w0FaT*Q)pN^?6-wJ0ov*-ErH3lDeML>2$hx!Qzp} zeq(Lqg-aSexnSu!NDV=dMpSEDVC0HtzcjdzxQ{_F>_!XnIxuDa&Q>+c>nZYnXW$BX&eoe7~;aY>5@3gV;hAsM z4SD_+{x2WjxQ{Y7L@S>48p+x=6>71@50pU&1qWE}VEVW9BJSfDBAx_Lk zOP9BnhNDgDoEPd5d65XY%e96E9Mj8@UB=|0AuyD6DIA!F9F{{Q{R*a~9|$_3*0w)S zEBDrP<=sYW)-*R1P7fB>^;BoZnJaSwEQUF5B$CvFQ+t=w4BQb=t)Li7@mN$7Mw)wH zdT;ORXB492@VC!@eDj93_aB{J;~6`@)Vrk8Ya5)g3D?_j65AFKYjNoiod8;Cnx2wU zPSeoJxpX@n*qkXcax9%mOJThJGJ?c{Qt$Z6WQ}Iyc?mj?M*q9C;xB5wfxV53rcg-j4Rz+)XJnv+tAM zG&MHV{-k-fRzEMfBY#nD<-N**Y$DA6;tPGLU}xb$67$As$!8IA%yC{5n{D@&E~wM94_4F2s(N!f9q+R^|rpcVdm}9_9LdLG;)$HPkM)r-< z*aT?uV-<;_`QpT5*f=4Vqh7jzg(pB$koCLJPp1g%*TCPasNC%g({Wc?SV?r6l(_Kv zm@}J;>N1I~hA2;5k}hk@9InQtn26K- zsdw~D%Dl6G>^Ifl20kh;y7!u*IP;$M2*0|Ilp##3Z1-h1aL>b6D5lL6@M3k^6=caI zu1ZKZ;Dq5)sNvDh(30~3?20(|_z|vP(jpcDT`#&?khkcg*(s_yO9v|-yhY#nT(-%w z(^}C#`=9Kb<@I_zkPk+Z0maMg@iHIOXZnI~5%Ty$s+hU4gG8=tCi8K|o0(^Vll8cR z9-f|n1-FzHw_$%+X8U5Dr}RU+#PQbdSN9Be9`(kDgEgyL_9l1w3`^5&TEzQwJLc(m z^_rOfBruCfa1^bS88{?|9knIP=8TQg_{Z7hN1Do)1@E7Ge`fmBO^4=Od~(O0)RpfRHgs9HS44_EX}0g~ z2hxs{=zsEWb?-eh6($&c{f%qmm1{~8g$avEstCeYCwhd}N|5ffbEp}MD_Qop(Hr?C z^dB&jUaK?Q(6%g{uk#c6@re!p-qqV)yr|e) zT-knm7+ffnPkfsE(5kc)6~OT|lZ3?r!2&I%<-Nf$bpY~u*i4#}SXfzpf3P-l_WI-d zUL3gcNa*#xy3R<`YHD2Dyf$z2!^1sd#m#SxUbyk=ubz7GtH;hPtE>$M`Q(lmef??i z#sddV_p#!-idNBDut?5^*U_)ysQH#2<1Q(Nj{ozE8BB-VsGvunA_X<#1dqr;8mFm& z0mm?|;8JNi{_u~Kr$MbOUsW^?lFJKwo;cv~VlCQuVh4^S#qus&~F*s~z>@DmI7PReBD z9?vYgJi-y;{S?qUwP$CGS%2T=-&XuKzM+24!}TkEwQc@ar;w~aX}KCh$x7E1HCs~; zAs27V$;ru{gUYM>H>k(HHh+nHsm{EGOdxNr34sFb82ro-Bnk= zx#*6^hMsMR{_Fqdo__hW;g{F_xX<%r`(5@$IeCPMqefrZm@r9hNgugqqK+^{M3(FA zx(s!lGQpo|Iu5PP8lZW7JRRiNdbqCDD&hgV1I%CcZ5s7*_vc=9{QxRm6zL&R-& zHc&7y!<2%aag0M{fQppP4S=~bCdyxwbaoEq3z{J?*2$5`{B7HNMBU*hw$`OW=00=# zZABFgq1W1&tb1|w3ma-$iWy{kb(+}Wwqcxpn{sUGYoE07c<6r56TuZ%Zz}lWlk9Ub z9>Orl%sI=Rx~yA6O=aSs;*zM)?h3K7*A?15qmaPY6J5pzr~<@b}Kz#uUmSSgIJaKIj+rsJLz#<~}ci+1lwO|K^k zoi5$$SnL|&K+2;U%`xYDaaJefp#eQqij9aHmjbp>T0$*J3H&yVsY5(mRfJ}m5ck81 zFtV~zMT8{GeoC#FU!DrRW;;fDkKy19GD1E93W*B5_m!4)wbk^54qzWV`svf2+9^+O zw)Ud9Wk4Dx(pG$_>>OYOTizq967=la{H zFOJBS4?fYrY4RKUF)lRYtql)FYiYc3|CH5TZm{4Vq-Q(z_0i>@j=R>Ug+|U+ucM7@&59kCdZbrN)S0mTBRSK)`XtY{&u1@>sKx- zxz`G>%9|yUwL1|PIC!fDE{W18il~<$#=sg1Tk9^fv2kzk47v4~QH&CYq9&nOpyS3$IE*eIyZfx+5E+Ui$FK zcdz({E6N#?unx!TL47u1WQ@RXHC!$b7>B)@#CXevVPwHPdOj_L6ce#4NhB>wZMpy) z#ee-U_UMY?|Gnzy=w0(HHu-lhy63atCWl6UdJsd8PC|n=osTb8L3uHi{6YpnY$x82 zSf;sKUjBY8YfC8Zz3T1nhQ0CniLd7s&)|*C$*sul9*rdeN;;j{jK(FH<7*W-!1wNP zRQB?HD|f{1YJ2}MP2D(r*Vj+{;loM)Dh3zHnnIGRKd~laUDK}8oc<2({z^Zwddbu4 zqgY2Zww9h)4drMT>{@cJ%{G;XweQ41Mt=LmDpkzZT(fP-Y*6saP5+x z$V4WKkW6fFs>yWtNDeKFG$5nx!MJBAMtkD9+KUso-^%e<0c z^KBiSj1+1mo-cWR^JC4vblu^)2V~y8_issVy@S1Q=gYsiKp($S_R-H@82;dBPuA#l z_jrkdQo_bO#V*^Nn@m2KtmU1(yt~88euR5{Z zO7~qB zGkG_%fI&&j^(SnqM%ON-$O~vJHy8DXhm}C*Zlc8=I_r)lEqNqTo%ip{3JOFKbqeT# z8b)~ipdd$uc{sw;-Qy@=(l$yP4CpCn<3!qQxj}-Bg7N*kPvQz_;eAwwE7oFzga7#K z!Izl?^GIVdVg_ER0>Tlk{d}fFo?uWynr)_%pI@+YF+=GI0h}h$2+5pfyD1Itr)DAr zr%Nxr-L%nUf<%l^u$C3ajVz{V(T13%Z)un52L;Zv3=y)QJ-gG%$fy zpee`n0}IEWbl>gMeA85JK|h-A zXphc>zq$Wc0U_>I#vgYj5tk4G{v>`#QNPZ@K#LP13;qDC)QN;|k=;(tHwYZ4h@lVz z!0BYIY literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame16_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame16_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..0fe4ce786b2bc1d91906241b26f0a99b80daff6f GIT binary patch literal 2257 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame18_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame18_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..869852797b296a7db21ee9057d483c9accd81ad1 GIT binary patch literal 5106 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame19_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame19_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..c65c1778c824fdc680244a5e52560f9721adea2d GIT binary patch literal 2133 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame20_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame20_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..b4b6c349c0a5fa6855f2473b4109391cc416be08 GIT binary patch literal 2056 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

r<8*Wj zt#I6?_9ri2{p3>NmV+T}bvou$I=#qWMM=fhO-r7>M-*j}QoG_V(uuxZO?1o&4Gl`v zkT)9>E}9eEFs-gat-8Oi?t#>q)Q^U`yJs}*#IR(T`9&eg&;K^P3#o@Qh(=MaVaULU z6oNYM@Q)xlOmnz%_@z7N9}5;!kC8lmeYIhD>&Ttgi6 zSGv-_(K10@S|+J-ynXqiMP36AWtabTzgD<^a9aC}>foQ^ogF+AZ5S<|4 z6uBe-`7Tc6+Lb%(U;YL753zvz7~>pZSKhLJUvKW=SJjjMUt!(Fzu>KHqD%>BNm)|cBHquT32W5hJf|vwv0vEt^NfK}X3AZJQ0HEF8`}^!RFU{|3eX{`s!$_)&ZNxRv+GecdM^uez{deh6O54fqL) zzYSf00KXA_C~GhsIsXO`|NJ;ZAg>Xrj#|samDgX4?+*9-|9&IY2$L56?rL-J?(ANC zRx-v7iACLA_9G2`JZUe;Ksu$@9yUU`|(eaT@7D={3yd;n6JY8)Iwe;ue2yQ5O5)2 zLiSh(iUgq@c&Hug#g~hY%USRE55NEM5M_9nF@m6VqI>(fu_+d4OEDjQ3(4*U_!D({ zm~Lv9U!D&zxoSCq3klG*O%%X83Ri@$1PER*yk&lZ@vHs~_8))6df?#jtHTrtg}=L+ z_2X(z;123NVqW{V-*iR0{_5xE4_uyE)_-`llD!fa`r5bu~?&l^v-^LOw8#(!EJ2O0EV3lvK`mg`%r|r{c`;tx@xVVpMp+C2crPtO zSXbe@P?=28Evo`5la#1ZJr)fP{_cCU?Z%NFw`B!5xDheu4M-D{!MYWC^85=$Uk3tS zoTPTq(5ODw>2SYwd4m1r`|1nAzX~LrsdseJE*zl+QVwc9Z|>+HlC&ex%VMG;CDv?c zk$4h-USn6oE-V6$tH==Qf%=Q8@Ny2S^CRHPlR0^s{s~L3;a|l`JYXulV=ovfCXr;4 zFqF(6-hAKfo|UFOiFJk7E^@^HuzFJh~S=NfV(J)mtmh3&nZL{zd$&&W2)N z%`5fiYx&onFX)`HnC;R=abg`rSXm55k#w6yS~Q%Z>Y{J%=n{)C0sjS6I<}&>7Gkbm ze7~e-Rp;gU7?K&8dIIzn{X;XIVP6IPV>K`AUW7ARf|sGIN251e7Xn4h zj0EhV?*8tgLxN|9ZUW-3MOAfrGo5sXHqZzhHR$Wo7o@N48Nqr|^tJuf059(bW&R7( z74lK|CQ{yBnqy+C=$tj_z#1mt2CS_xB49s%cT%c}FoQ@`7iSa>;*=Z zbzEqzVbTmN+h6Dql~&xa^)K~F80-D2WR*IIj#~K1{;CxFW1(jYJ5jNY=Rc`G7t^tNy81 zTt>Noi_;j7q^X`GrZqw516K7>Bh!C(Ua5L8zzTcuL7rvO{)!Niy18Zh0YQRe+usCz zL~H)k8B}1ih>CvW{0HE~2)j%H&HfBezq_u2XmBYjff3j`%7rGWgsFfaG+TZod^h4d z_(5&Jx~Rw+j3k7q$mt9;n2X=t*(`npStQ{w>Lm)Aj}5-QCTk76D*ksJ{YnH}`jPU}cq9MFv3l1=y8j#{TZ^ zL`*X8fgoTJJV=}SySW3{W#0*9;cyoK3xgAXcXu3l1iJ{k0J{RypG7x;MD%+pvd73yth@WWxK^QrgMeLtU4lLE zi?GYO55bYm{oUMB!X{jRJ(pmYd_Wumk%pq~_ z+hF!xh+KOoYFw9n{oUMjRyi5$UV>j5G+@a1M^a0FcXuQN_sf!OV(FbfBp!S4Mqm2%Cr;(gQ@tPl%VPANLH;Tiw^PXCn^X&%F^66KW{rX*$NFw4?*abIdGZ5D+FX=KckuoQvOL~{Mw z_!|CTv;WWIAN>A~^z;L8W$GmKQByB*n`5eCrs%%KM1l+;;tc&WNZCu1U$vJ^e!0*? zT#`<1CJaVo37@W##awl)byW6v9EFcYzbK6NY7hF$z1NIC66b~jHYAvIk_A>g zo84r@VDN7?NoD~$G62IDs5t8w&8f6L(9dqQW2g(&k5Q%hQeYa>3G)N=-rmV28T8fO z$%P0Aqbx}PPjs4J%pJyisJSrVvnf_!7X4{D^>${wY7CU_U)||671d&73!!HC0}PTDEF!<_wVS<$*>a|Y?fws=)TV{!bq7k7xfx^oK#>daZcyw*@y zPpw-Qkj|Kyrw~wk+#X%?=+9RGxD3bHu7JW_d5MwuKt%v ze_c;O@APip3N@86XSg(nmU2w~&0{s#h zLz)}yJO8I-$@TL`<*KI}rffRnn3_;%A5y3$Ts-^X-<*Byq~f!Xy#eYCtTp3NA9FgLB*Q{bm=tV!K>&(f z3K}AutOTQ#F`c+9pGt-#5}-xp_B#^Ut!RXC`SO%TPwrf@B+yeiz_u1`>+UG72xhKy zK3Z{zYms##Jk zjA!nyPElb38HdRXHW<)$*I#wkZfY$-i~s#K*eF_}BJs1f%NIx76V!3(;PSi~FR5eC zw5{4RuySCT)-a<+HL23mSY^7aM{TW$K_mG|>=Mo?1=|DUlS*1eq**@V_PIPBWP8c_ zBmOG*LlUZfY40d$aQiLw_By?gXR^`gmXK7`RlJ2l09ixdg8iy3oQ8GUM$N$kofxCS zNDB?W@gw-z1rsnE!?0{gv9}jvnlh~}4jJRHxF+RtPTS>ZJV=3L+`lhBgPyB$AT^SO zhbyNquXAkr{BeV8TcFEnu%MIWwwbkrFvavVP~`^APT#2c;P3jP?f>8*Flxx5m>y6xAF>N=xidra=ySbtz*x%|b@C2(4mt9FbaF@D) zo!K;HudAobqC#hH+RL8lotUVTckRy?(-dBaN2r1)yi!E-yBfyk{@~rVe~z}z-ZM!? zUCX<76_ie&ui;1cI4DOkbdz+*R1D$GvS>wb0!X|$C?&SeBI?L-{73i%bfFf^Q=-?% zBF!TL=!iBG>sI@H10$E7IeqozM+VQ&2tHNQ=L@U5X3;$`B~V3!{*SL(N;1%KXfFa$`|hhZ(l6zHJvDhEnfz;2R%OGMmF*Fg|pK2M}ar4R{zJ1CMb5YNfZwzSac3@v8X_xfF{!{nQ+l_UT@+oln?3hr@!__?PiH+G=TlNs?JYpuz${75 zkQ9!6_8x*vknzQtFF(Ee`El>G;OBq)Aec<d|#4;*XbAjeq}Yb0Exaq6B2m2JC`wM$d~p;wGUQ30=Ja z;%6LhyR?9g`tQOZrrI5WiGp!@7P zsOT!=W+P}rqr}SI3ej97gUIE=U5Idl;F;WuF3sYk7}}hKkgjYxD6LbZa$&56h^FY+MR06@3sDX^iWpPDQ9|0}9h&(j`l>{PV*`Lw>?*wyPKb}% zJ4?o5>9Ha%Bb*eCtCLD^T(M`#Sn%$~XFVoUpkLcV$t|ANMQKRyl|K4v(O2~Ky zv-Pkk5{HtBWODBMSv1aK5CDxfDudD?7OdIPHNBxgDa&{y`0RZ>FFLM$c2Fs;C@IKT zy}M9f%8vP8na@q1@c8P16X#4t61>vhD;iTcxQLA^iZB$&6sibuS#HaKZs7SO4w`rb z^v)Bf&_PlJbi_O+gPXo6_m)>9l=;hPQ)$)z3h0}GnG{`0$n-|1U4|Lr|QQ>7hlOV^A&cT`V3 zee@$&_UgDC^w$+{{8@C{$2WJ}TASe*b4O`lRlE0C*L|_$%?p3Jbk8C3^g36_8v{F@ z`?+%e`%O&CSe-w=swR~VzkXSx4cu^L@yE-YU4gCpGxfT27Z>iYiLXE87(VeI`S)Z; zG$A?XfT^UBAnL|CEPcAyu#^^^YUgOlxy%d520ArEC8UeMLT*P1QN`*ls^WrC-a0JL zjTXbG-}r~j&%>o*zpRQZpPTaF(|I=>f--bF8+CZP08UcZc@O`VkJVmVv-z=i+s1#= zy(h7zrztmBI+AvBRb@P7sxk8|*1BVr)nFXx@OLch2z9vT9`P6SmaUl`?5WzWjJ*C5 z9WHd78Oa#j$Lo_nx#N#5aTHwFoZVHDi^|c#SLd%P&(v52-OeFVkK0!#Imx5woFWUA zn4ho`Qlya5C#c27X7N%{S^B}H{onfDl^1X9cs)i<&L?7}F>j1^(F+Hq(uiY*_1Q+< z**QP(_gD9wzwyz&4I;Cz=D5FZW4}&|BBCK&7h2gmyYJmE6dn*~C*9p&y#M{S4}Yi% zN*hDA5ke4FqAO*+1<)ZJRsbDJWtVl_pyJXXq+ZK5VB2F{Ekw!qj72DoMq-pMKKWb=4kiYKQ#8Wl*bkGosV97?N}<7=Czh>%?c#m zQ1bR%97)R#`f4)f$MufUg0*83N|IcD{rU$NHoUp~i}R^-nif$}O~gS|ZxBm}^iS0} zkvc4iQH1oym`L-9@{oenOe4!wXzom(VOJ6ynlZ?L+UnNnbfWYyKHKN#=M)ff&gq6Q zxCo2Jol7`d!ws|jiw>?izCSV}JZIp?{ma(X92EtP4Jo)w-je0IzIz$@O_qnbl-4Px z8osWoQfh8*UtYMawxqn0$!@tgf|B86iCU%ho5ks>kH%6@SUI%k+3nvxRxyo#KJLo7 z?N9m@7m9#IiKCD#kqf;Ja4187hNfOC3nTpnN4aCR5HNCU7FiavBZ=}Dg8$psnDHOY zd#kxRT7Pjtb&EA~&fxw9>z-WJvLg8OnDS4bLbn73mp3+YUQ|Ln9cm->O0G9~qq}My z1$SSw+9oDglBrU88(Cr+67B)Wg@TGrM`FJH-!D{^E4tf&P-0h0q!82lER1LM;9mie zhGF9TDUFAsBePsF5qvcB+X&3B)2>k}mEM?yA}}bo!*xq``je9iWz)ZV4LbnDt*Xx9 zr#3Io&1<_l!ef4f%#hUp!;}yXxQdG81O|=<)Du8L$;;{^trkQGOn2Ivf=fHdCn1_;+#TCF)L5(d-PCIApE`Hj8ac1DNz(9M3Vh~=jbTk>Jz&N5T!|n~?NvBeR zAf7oRyOmtZ!@@k|pwqgC;oMgUpdoz$oB*f9ctOVPXGUE2zyeCck_mj(;?;F40k;@d zAG zGmKDP1=AfoHPykn3ji@oF@)n+<)VU7`_-7o{{AHpAS0Bh2*nFsfTU1x8kxbMV0CeS vo%c_`#vj-bG)6+0aJ1M*DPO>*PTVKDkXfgbM0BiH^^ zY`^t?^8fO>Nnk;`7$^J~ZWaU-cLC6rpAnmmkb``II`NJF_uufZ$R5G0D;KOj{_YiF zSih_>YV*wK6+!-tj@UHMf>c9;((E{8`7EmlK8d=bU zu6TcRgD-ptFoESI8gqIpAV71~{G8kHUw`&5_zv*h;6&fu#H>Ckj7q|>c8|szoKcAH z!K1oC!F{JWiXcw207d||u?h*|wAVc_4?YYmD_=OVCT|^*Zzv9zICuOD>|mhc9HOm+ zdB3}w7`!LgefL(JEHddu-+^}mNarz5H3FHWCKVT9SzsPvTfHQN%UC_9SzrtRuo|gPvx}JGLPBH1fyPS}eRss2@lM?>> z{sr$jvjEkAgT@Y4??21f4v=tNJQq(24g&zXp#^|_$`6YH3eSgqj0gN#!AMaJ zLR9CKuKw1a* zrH6m9iCs7l<^+O`CGaJnZ9Dt9x5U7KI0>#A1t*I#K_i`m0Y(C+Ov19`mp%yRviLTL zY_uRk1Yj(pj641%y21_w;0wf%h=G6``m=v`I`@Nu5S`NnQMvOaIkyO4E_WEEprpsl z3opk3%CEqeu6_lO;Txog9RzF!B4JslaQQAnF^pr3exYiTwV9Y03Ubt|dsgQ&fN*Cn+ ze|JK2!lg7SZyQdfKhuYgQu27|5TilJ0*;sl#2;l)iP9xn_i)6rnJxmBpb_7fa!&yc@mGx4{x)5s6 zKN?uj;{s^Gt?GoT&Hddef}fd~H&URI%v!C@%$6kt;28J-5rHcLV1{5$cUA=kI|7wg zm=z{!F9!uR0+t~F!G}=XvTcWiR2t_?X607}i%x~`M2K_2iKPl8Ui{R^sZXv!9 zawK3YWkxC>lDaaRb2))pwaSYhWjtYGuh&9?LY8mV1&j-E2+E<6f{&3n)Fy;46cxh# z{oP2Bn%iYhGU-((EJ)%eRA(~czKc>81=VV#yUvu#eXu;utBgdmtqw% zFJL3&w>1R^gh)kDJ^kHS%vCrLCQ0Pqiuq?X)~ixZohxFZd`wGKhm=}vYK%%|<lLdd;A9o3O!x{J)j70Ydib9xYn{gz`pT4y>&WEx z1BYHof;hdv9{c;dxbr4+3)+xS7yw*AcfgVRPJ5)5{_gHobeE;b?Yh%;ytSF{I0@(E zod31I{q6nly?-vpGATg#2!>k*ktWEXJQ75*yaY`|>3Td)KKJct`t6(jEmu2RzPbMR z-MYH< zN8kAPr_I6Oq%4gTh2q2onVM@X*8gLPAO)a^0gNuhb(bne1sY5M(^6U?Y%7r@8^k${ zmU^f8aADKx1Z8;zENmh0~#>i%9ADVdk9v_i=tyNNF zn!254qE}}w%Js!}Tn%=GefwVj=~>bxwnaV=Al@zyw}V6pOX+c{#9#@_;*AStC_;>X zIW!cb_q?&yzv{dF#bEQYx=8%i$ce!D(BWlAmT8rGY3f2n-i~>kyfkLmpt4>9s|c)Q z5Ns9!Bc!V`=^s!gQPf0-E-aqlDk7a!l{ATJ-p)e@(RM)6h~y`scabEiE|^7z6xlwjYG+EFzrL!i%FhN~Vo<-*H4#cMYlOHbmKXyS_Vu&NRNhw`;mLEWC-*+L z|3t4hd~oEA!1cxP?RTdvoAJ%}GoLEhR`_Ga7^%89Gt0#zCWQbLZjL`_qLZGEH=Wuo zw3G(5h;@?*j~5a^rUNNAIfgK(8eg8+^Vp3KZhEiKo>LX6_SJrm zAKxnrH?R3rYOSK>)MXcGL7%KPKdI!~F5V&=ARe{I;|lmWuX1W_w4gLxHmI4p)=cmj_YIx!z!(nt zv4AU=;1kxK!yc3`IgbnH@wP)xu5Yc)s~!3KYoFO2EpuM-=bRW>JaS^-!TWWR8A(?3 z5qZBlFgC{zsHQiYTuUCUKjLsYWlod_1h=V+9q zEgJ1TQ*}6FeM(o?hT77yJpbaJ3P<#B=Orh|_2H5VyVIH;U@Gp6-IK~?p3xNdk`wMC zupY1D4YM19*-((r?z;AB%cqrTkI3QPKv?&!MAbH|Z^bq;Gdre2J5OppZW&8z$B zz4dj)nPf8kZTrJhTc1bNAtHBJ%RASQ3p4AAxGx8CTb(K#d{xa>)Bbg>+#L!-sxdd`@vk3 zTv{ruVr4=JnQ>t(`K`yI>lCrptkwYFHuany%!z#XTuJZAr*2HuUrFiP^W65^jEX$S zlkhuoRRp!cd;1rjq6yLriGs4GL#nDG8pwbKKCILW-iQ(!N1*1F$PpO{%`$4dGfZ2qD?b?lHV@|oDO_)Y+eVfhbZvQi{bOlYbu z2`tVp3M2qTMLJHb4fd5qTLgDvW7;rq7Zq>aMGe)Wh$|Z^cWyk=*1CKBO0_ZH_pg)J zm(7Akh<-sY$*^!NSXfX1z{eOt<5Mm&$D%DAZC@V0a(~_54b;{~hR*fXRd+0qpY{&U zE}xb;lO5(nxhk^07`N0Mh)7t*fFB{nyU0%*ca>_6{p;cH`Ls=m@EQdnwC*9w! z;!#!4o1JZyQlhk9?+NVR);RLT6O*$@Nww2=v*!N{RGPWv&M&%)MY6KqFI+@{s@tQf z9x@Ht=|t70>FWA3CvM1Wuy5nBB)xWZUdyTaBAU}!P&=sx0niw`Y#9TD$-+EusHjj3 zgdh?wcNA?XV~DE^5dr%PZi#(t8gMRE#~v}#Wlck~PHm`QG&O@o|J1o0F-1Zcb1OFe z^7Lep9cB{93u55sCaq{lDjH*)dMLHBM6!FPjVg!ze^EFKq@eUV#y+8 zVu>q9C98-N=_pFT4?+Pt`7nYGkapW*meeR3@P;ReY_V6yM<4v;8e%m@BWJFX$(lXFz9SQ^} zQE6h!nz5P@+UWV6|44pELnu;ZO7W}o+*0Ssh7#mWIEq3QpYLX zBrWQf+Gp*o>kTtco>o^syQ-@<@cX5d_o`eLna#Tf!iR1>9j=)b_Ghb&HoKe~bapN4 zd2(;4$;kh`zxry)6OGbVT0vEsXs2T&z3kKI(%sK!tpC8Avzrcl1WGTKZQRwc-f4@_ z{icnf;x)*F*yv2?>N?93Lb8k}U`GTZ@G7)UiMM4VBM=WDG(i)elVU^+-bk&O!fC?t z%849=Ns68r)>8L&Fa0S0oj0gVpQ`WhbReuSTfJOuS08FsLs(+^=mpnt`ycdB)m*P! zaJ`C@_eoi_e?a@q*rvMs796I1`7PX@uNna-C z25O9GotgVzAADr z7RXr$kEjHo2_vAw9;8tcqew>c=|xkDa+uJfVq8)wk&YqbX%KlH8<3MB^X`n@E3_3& z#T^sM9KP&hlqE*31M7%*=W8nmd;a@B>bGpHpYO1Buw0@@G-2rxxl!}_?MoRVCcHK~ zJk%#^fmj+%%ylbXYEnvg75zH9d&=`Ao8_;^(nct+ z;^t=t`3$bYHYisR>~Q1wC{W4@Ry2H1(TsM0x1GdvX)f zq)rlT!mR7->;3%J(ih)cjlHP4g^D76inR5MRafNgfAho5$B!LDFgGS}{2`e$(f`|{>cQTsbyLYW>3>EACGrNZz^?{NMt4t-a@o*$owcDTZ!_DuNc)iyo$V-U)$_#Rkr;&qxOKY?jR{%zSb>J4Ni~i-P@kaI$@EGIqE@b=vE)q^i z(wHOd(KXvk9ods8>YjQBCt9j4*uF zU|EO@vV3pmf2|`|(H?5+3N>rKU{lX(BgZ;{EdU`_?TGq84MA_5NXc$jV?yQOx($2!~G<#A_|*#9_>tf@mmTE-{oEndWB-n@uCDF(co7TJVY=!ozx)fl zF!MRsx!EsgTG>;*0^nIe!9U<0LcxXb9VjXClW%Pcf_%UcY^#ePQ(!BXF9_>DYTM_j)W(Bzqkk1fs zVSmr(kcq6YsJQr#=v5P@k}ZHM{tf^93m7pY;XA;Of@0s@#2tK6C5Lx`06V(0y~4dd zST}0PC8@xa+OSS`uw5Z{N-n{Lf_Z}mNGgMpfre8}0y*0NEIa@2z`Gv9h_xQ5P5s>2 zL*blW@70RNVLrh@zW~`&Sl+>zp;g&*_y?E=m@uH>LBO<2G||=Q5Dtv;0I=VF1^5@; z;k=<7rr_mkzq^`x90Ld{z)l6*?$B^-U|sO~jPx_wDWG~*hS)ae_+0W91(BM6z_R!Q zmdZ3?`J2ELXTgnMeT)nH?04c7sDTu(N7YGh?&nG!NNgy3z`MgKE94J)pe}az3tq!a zGTkUR7KcLCOWhAW2s3cuD1Z|xbQ`LzBR&4c1^xHnUx*1@?h^)3in3Kjzq_46V8otm zNl{Vz1WkIWFx>7#av*gUTt}Ero|a=Ikdc69;lW8zv650)4&U%^_!sqBl0e13 z2K-}(5sENGWno{xyPOTQiAuo~aDeh+U;YV63eso8%OFj7I1q3lU_!txVBjOTR3a5n zvQRS%ozw8_Ux9zb3qe0f6+oS~p311V_j8dRaAOHgdO2Nmq4KTvy~0fOT3{Boh**P| zgNA6$s%$VcbGwVgvG4D}7c?H~*phI$)n$PzRZe&JbK#-P5Q%#M@$s*ZcC19KYN~U* zidqUloYoDO!m{I@592?%-kX4bt3Dlz{v@vF2GyeoqNRs{Eea36yPJQU8lz3^B-Kuc z7GoYUy;_p+w}YMYo%o+9?X!zq3or02s74eK#MPk5yQLhv4*!Q)rD&B4e%Ilmhsnpe=T`XcU_Q z%iviR>;^g6UWQ^00y}H<22Mpd3*{xXg?sQ~PPytxFb+@9dln;}MO$PU9@Uc80S>rmeBy8J=_fR*@FnDZR?^;g z;?8)f$8R^{j8m2>dDbZMZ{%gqC4#%28lvg}4S1^*stskeMO|CXM4<*6in|mFyEf`@ z6B^yv6O2=pbW9h;zkhd1E~d)j!63~~0zM${I*P);bwa|i4QDpo^D3D(;D)HJ&onjT z3og%<$bUn#a7kRLWpvYddmLUQ*g0WngBS*w)cBO>h(@V{7r(owFs=QjlMKCXlegf< z!xhkILb$qi_2eYSlBt}ClWbm<^P;w53YIxL_ZECxNr#mkOu=H!lNV2{oNVCJXRpCAWY9qQMS^hN%+>^xxgmcZn$( zII3v>ZSX8q%o_b(-u~{y zd?&|@km~{_;I{ZP55lf@_jh-2W8qA1?(G{6?lKPk?(Qso{oULv8~gkF`}@1O_OgOm zzq`AgFr>LaquFNu?(S6YgL*kWiE@FzyStS#aDyI`A4xI~NwADl8~eMs<(LUK{^|;$ zCjKf?Yy_KscXu4K0R<*Oz(*-JqtR3(*fCO=_y{}uyST%?0P$r3f>jvw0P$qT{_gH9 zN&}FvDy-08>P-{0Tg-{0Tg R-{0Tg-{0Tg-{0Tg-vI3v1{44Q literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame23_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame23_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..90f3fd81cd5d1ce63a529228f8a2d88ec0b96807 GIT binary patch literal 5252 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame24_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame24_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..11ba84cb3bb2638c650937c89aac425b4a0b2f76 GIT binary patch literal 5390 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame25_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame25_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..90f0ed873f72290e2185301013eb4bfe406cbbd6 GIT binary patch literal 2069 zcmV+w2fF>9gP*or+&J~7SW2U|L?u{Ps^{XC$GN#?iGDmKd-TG z_B^5vU4uwW3%l?x@QuW=DgbO0R;mjTg$n`^{Yb=d#B?cyk`j!Pa3l!#|L`yG!Lb(n zy8He8+&cQQSKksQ6YlU4EnP~8a4*2S!<^OZ31|RR`X3{#VM4%#&+xf63?D_uz)Ugt zY>L@NLdM?wEC0Z~^IhH>ya=27xRv+CF|e=iqPX{0fcy)>_7@Br#1fW(1uLk;?idh! zEEK@QJi&s63mzi`xfYK`WVc*|->|(^gq^ADvZF7lyd#h4U0`CGngyC56h9?x? z)S_=X1%rZr!Lq}Jgb!5dD*u^!Hg7bB5i(#NSzsk|%me%wSXV$=1z;!;X+pMCQv(0&Ut;&)@ejy> z6tAc*oUQ%b>IaEhqD00ZwDhBaV*>8{1>KAk>N%zv84cbMYk+uv#T>QMMjMI*7PWg- zMfd-Kf55%>;!re0j2mz(W^@hx-1Hn-iBVv_kN0=jz+k8wkprBP$ny>sJQ!C24Z@=# zx^w-VJ0AxBW8aHF#js7_hhX9c@9yTk9wF`l`L;GC_jkVg7vMSnIsc6$Ezt-Yv%;l= z;b6K!z)I>&IzfeNv{dQ;)B#E~z@DFxr5FY&i!47r=#rX4I-KQ3-S^`YPj09McZ}fnxg@a{$+XErHu<&kIie z?r~|&{UxvgYCZ&fSK!#a)CJTCX_!P0gYY34z7`BFLMp&!)!=rDjlP%Im%jTLLSTd3 zqokY~js}m7c2wK@x#1N^`Jf(f3o;|vyPbKpNYbvi^#y~0Wq@Xjp+qn&zxWVcK;1zU zNa_j_cz6GaFrSIPk$6(j19$gxY#4cxo*W8ARJb5njEKf60_jeS+DtouVZlw4HiTJ! zf;q+3QjEM2Y{dL4{uyD%3WDD^NV$0A;m##B}yL7O(?Z2jQNibC! zXlb=jai4CK*O1n&Ta#@Ird;DoqHpf!fjA`}FBl~ZQN#*^ix(NCpHD3=5Td0Fz>LFA z4LUDe(>BhvEQPbFt1Iz)YH(E%Bz7}vnTWqcc%~zaDGwot{VfFr3!)wb#{TY$=?zD0 z33(p|HghrQ(HeO`r(2oA`;9&|049{lw-=gT$y=y0LqgL0U$K8dohW%wBK%R}fl97J zB}o>Qf^A#W-Gk!XR z1?fyBR_8fZJSk8dV-{>vdpgwt0CbzDs+|R2=^ekjqaOgUO6(a<)7Fa7Q-L1}vyDh~ z$;-6Ib&AV}j2~QcZsHCI)T$Ag*A@Zx>Jl($Lz(2DFen>{@}Q-0R^S{3E)fnWD~Eq~ zQTPf@g>>JPQ*T^Ix|3^w!69pe)JPr+UXr8l0%;gV<%bAJR0@)H3J7`2HUu`h@WqIW zRBORXz@nh=iMYe}_jP4VyHe8<6k3V+j~bY2d#5%D&R1B+II)gCz?yI_018(bK(SU= z=P!z5Jw-Bq#C<@tQb(uOPNh3krvC1|w$5RIki^8G7VsctR`G{I64!;2ptuuG2`1k3 zoE{BAoHb55g{Vc;G5Dg>+F%Sgs9g_m9pN6E`?_-&NGoX5eMoE<^Y&spKt-zPHAPd@ zs8y}`RKN*N8#sbS;0VT5$5+OGl$B}uAAIk}(%+xxq@fL>mNELO_RTAUag4yPrKXqmse zyO3FcUV(52sZT2~g+7pQ0HyE41Jugb2LA5u1!cxTqVnV%3Wh}!sAmxbjNl;QTBUGr z?(X1M0j0G}Ld+xGEr4W5id2h$YCOxCl%rWc$Delw2(NREs{oUMZ0!QeZ%Gmc^C76YX z0feM}iIU{Ae|L8ta~Ay3@fRYrv*JZ*Wgd-`Gm%@ryStKmt@x-cM>wUPN6UNrySZor z%JwYOd&n@G`@6c3>wkZLcXvF%@9ysBPCowr{{H^{{{H^{{{H^{{{H^{{{H^>NKE;g literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame25_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame25_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..0b511e2c15dc6cd23b8b30c31e2dd16209434c2c GIT binary patch literal 5080 zcmX9?3v?7!n*Qomb*DOMNaX6G!fWSKdY)8U^ErLJ+zeW39fTD2qf7vucMfSlL8&-UX96N%x;ZQ)28GP3t2<0dqo18h;P%X;?j>9k5x z14SByUr>?lW;h=~T7%+B&+s|0g08fVTeKsEzi_23ENsERck4?BzMYf-LsLMznFfBC z6c;=@2vZAK5~3PmxG*v4SXb7%U&h+ndR8o}sLhgNrY{R9ZF$+;2}4BUH=#Eg?z=cE zHMQrUiPZNqgo#jll~hV=sd=x(HG2fllyFl|sMLMt!qlRoq6|$y7}S`64rOU^I~$q< z!?l8sBg5AiY*eO`qWu6nCjMj&Jg(t{`FN>DTln*iAi5H!``xV zq}$A+D+2{fbD|CByXqsyi*kE4J=dpTpei?PC_~e9pvHe3QK=dlfFJ+q;!Z(SzxQHA z1z{JibzEA~2tuQ3q*Ml>>#$@7@wZgI*8Ha}19xTNyDbBg__%^DN}A!KHNTf8^q1RK zw#kD`xS_9HG}Sa#JzUwM1p)!$I~e{Ywm-J;7q9p)wP!_*kQ^ZaH3I>IVW0{8CShB` z`v%0+m^Y%C9p}F2+uXlw`KmtC8FlsAKyJb4&}Y~Br5J172Wteu2wGK^5p?bdS_A?a zv$F1N2w49K@-pV436ztAvaQ=wGgWJvRm*-{^E@La&?*< zxN+hH#xAlUM*r^kqqELlT(B)SO%8);Q-$Jr2Cqt!@F&q;9eT4qkUF#Oy{6}j<-p<6 zfmxY>xz}owrO~=M%kSM()0nb8I&8hw;8@1H*q|)w0!u(w6-nX6j~_8!P22F+41+qz z54__&QYIe%p>#5WMdl8Eh3)FkO8@h}-O@c00)9S#b*1c|mrK@qOf|SiD%U+b zLl{;^h8>Ai9BS`8Jnq9Y*X+<>wnQ+ZGd)PvIS=n#~K zkX)G#UE@bCjT!_Iit`?*=J`xEfh;dBOf5d8H^fX4PQ?|NRnmx+RwgogcE#4o@dbIG zK9!-Rr6nmy@R5q*b_#_NJ+11k!>!%5Uu=uC)<4kPJnF;eW@l=LYa@Rfj|WCQGhwH{ zHv4nqo(m_7Dx8xeMwH#ZF4~yxnN$hsu`j40m`zGK)u7K76Qj=W=VNIOpR5=sg`^w5 z#i5;=EpSr7Cd4tZkLgk&Pe;Hnw-iyJs<*vnewnw@Wp@;=vXwUquOjP z?;L&VV_(e$f9LsGQiKxI?l18b0Ap*t7|npdh%z`1!yzG$pPWDtgqS(uUV2IJByt&J zmmo1!c)eM-7j5J{e1`h}7`xs8&|_uRI#Zu*>1WIR$f4zf{PIobv6Ov5ab$#`;KY>fi` z0PA$(;BaGg=*yL6cYM2}b@X!?akq&_x>vPyv${oi)*#wquVR zm~&uppk?Qhgs3XbOw?69Qo1hFr+MBc-ExZ0GdF*=r!*sx(0l7Py>;sJw1-EYsnPOH ze20|$hysf7Oo;!^%5A+O9?@N91nyEf*A6!Ijke87fG#WIG;nVb+ z6ok(v_&RJwlZ4|!0H^6SE!7p}lJv+=x+v*zNlDA&wP{ z38ZNZh;hz8Gwe5tidI+l4N6Z(z^-yTaVts_rmv~CRgOf~`oz3t(#aBI++%^}kbC6W z;^k9n%fm5o%XqEKFeI&HlzqWWAdLr-*(S0|EVj^QYUT&Ip;od7*>FghYLZZ>tsFTF z$72ec2{(`}wn=`HRb)KHh8ZkUgh2&u-L\+PtgDM^>i@&ayC&4!H5@%b8-t@Rs| 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 GzrVf^UB5Q~ literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame26_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame26_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..6f826f78995042d1608e811a632c367a86822d67 GIT binary patch literal 4820 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame27_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame27_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..5d40e0303738ede1827d5b311d747c009bdb77a8 GIT binary patch literal 4704 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 P=&VWidvAmlmc;)7WluMf literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame28_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame28_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..67084be7d69118132ec91761b20d4ef97e37cb60 GIT binary patch literal 1821 zcmV+&2jckM!5@L2|Ht?@;NJ51aT&W#W!ZK2c&r*jroLEqO05I&C0H=9Az_YSRql|m zE+R@YqIoC)p`@EpFMakfFT=5q0R}5LSpR{akN5X*hu~-Ye}xTSe0#ccPCZR%8yO=T z--^0*)S*O9r7Rkb)|hw@aF6&9um>%j= z_!-Cle|HFe27W)qtFHZtL{4r1nNZgE*u-|~RVkSk>Q*~2wzw{sP_dz45>Y)FPjV~J zW!ty^>^}d85%}-HAOF9*hCc%b{~qo5D3-f=r(|OM8y9#o$6qv>-~x4(c88W&N1p-~ z0pz`f2LO_UqaX&V!EYk(ehcq?hu>l{JBz}1haZ1;5P$J%%DV>c`c4&F)7}pejt%7o z{0U>z2wTw*LPDpZl&~PW9$`YQmrPWYY0#l=xkp2@!@t<~{8!Qx=#^`KcQh+FW`6Sy zUl(xQ;n!R4*UgU3Mwm0uBCk-0-3UCvvLOiA<^(JVSOn=MMF<|{QhuR`_*O6Y7vR4T z^f^hcg)@J5Hms!95yMU&cY#P4kHNdZv3hlsKA~Z8Evhaom&0ah<_(qs;s3(_0?}*9 z#z5|+iKgmX9{+=T@BiX1<2ouB3NcP^?&nieNvtZ>!ij*CQ_W%mBNvnv58Nev6g(Fk zT!Y6<2v`T05Rd;2(vWG!p;$Gl`%-QF_!s}*ek9G4iKttFK;qT!?&i!uLNi*piOIIs z3B%V5KKu%3m5YZkCX8*I7t_H0I$%MC{N+M}g$Dr@a1O^y%YDAa4e!9e;>48XQ`>+X zW;ge9V(Jb<+yKp68xce|efHY1fKNg-eJ#QmHb7z861r%g;KKj+NHCos@!C~`I?YE| zxBLt7VlDAc{HD;v;P39|18Txlv=(z$4BjR94~%c4{ZI8ARRB~div*F9DlT-ug$5VE zgM|ceT|~^5E2(xR{{qGT4sdOlj8y_Hni!Oh{_b?M5#-Si4QLIwY-Oelsr2R~R^X4PtMmP0* z!#g&st^ijMt3{L_f{wswBc96u%{z)_AVu{wxx;<`5HrOoH|RzLrtk&Y1iSmWx4aoc zKSmf1rYaT5c%PzwmxKfq0dU=+Fzm1(;6e7fIfDk)Ak_+*V2yoMP|on5ejI0+?L7*} zP6P=f0V0Mfo!{Nfee@h0CW1n@P%ykVB{Ea4s6CgUwuw^NU_rnfv|8xpk~;+Ew?fxV zctuxJCH00ZEAZbcbq~r?Z|;(l$=OUolr??*-2jSC?9h}j2iOnClv2i%rK(gw*()by zfK7r-QP)Gzi(j&ZR7A93LXbd=cBN&oufmuM6}MuUS&RvhP)QD)Y7?2e`??8|t^NUm zz)qq2M0|wYN&!ZLkOfg_Rs?SVo0M>Us)$WjQ#q#JsjFA8ZA`^x$7_a|!D6M{mC}Z& z*`TUxdjO{{xeo8{=o$Ch9|9RqB0f;AG{BU$pKQ^`BUjP5^f`zi|D?*cEQ_S zO&8e<0_tk6HFO=UUur3Uh&ZoxHVAV)BdD53lgi1&#GL*8-2zquv_Q9lkCjK3rYVgm zlA&Bu9f_S>WK~9@YND+QzDVw)w+e2r^7~oNrRw=tQ*ypms2^3TMf_?GAGr;I0&r&- z)274lVoRpR{_c~-I+N}V6wt~K9};X48;2WMgJh_#m98Ny!j^9!eWKM?v2LMk%)L-~Ar5d646Na%3-|YQ%N6mh91ALX(SGZ&BjG$E1RUyf z73<;y!EpPG&A&6Mf^|9&S@vO0k8lMNmHf*$k-t@-191>fQ(RQ{qH=Vw5Ws)R&L#`J z{oPP%5G>^KWPBw4d7{_1xX^)(5lRRq(3ecIx;0}9I!`$-XRP|;t^#4MVQriU`g10NYYqb^y;Rn zZvp_SaA@l2l=)CmXsg%}@FMV~oJS-rG~p&pG}YseikTS8wj_;>Y{@ySUN!_xJbr_xJaAb(V7P@9*#L@9*#L?(XW7 zz5U(Xh^@=7U{#fCe}8v(cLc(3?(XVCt^NJ|-Q4p7zq`AgIQ#qi`}_O*`}_O*`}_O* L`}_O*`}_Ogt@4sx literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame28_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/frame28_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..a6f6a4503e5731b830390057b46b6bdf2914b96e GIT binary patch literal 4538 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/paletteLookup.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene01/paletteLookup.bin new file mode 100644 index 0000000000000000000000000000000000000000..eb91749016beca50ae0412196347629dc3a97934 GIT binary patch literal 58324 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

0PG$d28f z(fk^WHK$$GHvgRU1JozD zS&B#rHmKxnt-fon(82rbNx)*<%>;nSuAzvr_H<@#M$Rm-$tG29C1;>@XK1fUL{sIZ z%u5aB7`%rUi%wfrFe6pFU;PoGxrf3B!|ov<)BGj1fMpbTEWXTqOQ0e>hyWNp0dNoE z*H9qN6&^KGOpgsz2Cx~O-9wQ_KRgK}3XHDvA6*=~W<~-80*Z1@u6vH&L~*y^`rR6q zTp$J7aHSSRFv&-&x^sZX^FTJ^*j|hM*UB8biRJ{*vawy=Xm_Fae}HGVk=); zhc?{(zfZVxyr9K82zuqi0y9GCBbEsD4^KIk!j1r&Ic=mkfGwaQ)6&|}6VVWIWHd5o zu9;{4+N>4>@#s-jJtL23d?nYU2zG>Syq~_V$yST|_LmyRkXezFYLw#nD<{|Dr|vCK zLrqsM5U-}r423*78u#ILApAnuOWcHpGb})CPHD(u7LnwMwWB9YFTXm-AqZIVtp}SF z;yUq}IC&Elouf_e7pJl{g}~~!60fT8Y=4Q{gV&*f$=idrTiQ={3|(O{hekALSVnZF zql$O1=-Q{rBb>DV(e|hj6wy)K!X<0@Ydp@3xeSULp91Uinx|=96KS+)Y%`|k#<>!V z*5e}t3)}^87PpX3Sl%!W&!mBaT-8^QKknCsJ2dZf&}EpIBK%kD_rNOGNH3Fqt&sBIJU+$*$dT zB%?f%rq!%|t6eD`yOyyPm&9dy`omM~gocG(-EY5h=7UG%Wny{c45cTTmziNk9vA|l z&>V1{{_PTophu7Oo@9}29bsZ`mCg^~6?XqLmWP48;89L=og3eT8qvNTYkv0)F8cvu zLD`7eLXeCZ@@?F4emc>&DN{^Ug$$(}BaYK{Rm#J$XJy27~IzXwKvqiGAbI0Pj`9e zr&B+-Vm{l+OU!&gPL%2Y?jujPwE&S*+q@qszsgexqp=Pb1q1=p>^m31-kAM1>;}FK z9tiu=GP2Y>Btg5Wc|iNf75JGrU3-6l+DxUXOxsD`H37LW|8OglXw}kQVwGtFd;}d| zH$QuM0nKjp2{5$%m_jUJYMLJZ^z_pttZM6KtRS!U&Vf1p8jB zYL17|hiJ(o;_u&l^{*9iHO68I$nR0?#(8O9lq9xTYJ4w|qDiq2+csVx+XRhlj@I zr&B`}s8b@%Ns|FDta@<%@|DCD4B5aPV69k{`UZwpP+YI-wa`BlQ4Z1Prx%<$t-M$| zi1P}9U#O(;i;{~fiAWM}AWpgdVr87Q0^;foM&)QFiIg&+o)|)vbFEa$^7LSz)=_b} zfA`TyJo(m^kt>q`y!}lxcaoGFq76Q0SxtSsEu(BJp-iO`!%Z|FdrBxd&~;;Y>@W@Q zV?>|`K4w`f%=`teOb_Ei8=%y5>lqb}jT{%E#>!95lCvD1mhHa)y~Y(m#Ts9JrG--5 ziJe<{kYv!xt&NV1%B@NtK~M$&c@z{nS2i}mY9X4Z-aB*lAOBLaQLO=ee7roRKl0Qu z)ABc0fzx;djeu=?*l{s&;mn^@rSe^ojoOIW+t#qcY{oKxq_RUehzyUv z@Jag&0cUs5+I6PZy(yYFNQ9*fu`^l@Xx235_>op(hB^SVVdvGzAZrFZ(^Sb7-F6+k z#m+1gwb|?)-6OP}bKgg-y*rz_3`D`N=A#o>B?-74$T6G!bT>`wCd;enZ*YX=_T}+Z z!@;Z!yLsOl1VewRF^J7Ll(@aCJ3OFW?x(wBQ!tM~iAh5(P4y~Jf>Zd18_k8i_aee{KhJoNA@wA-oBCV6$JxUh~C8pfp9->`6o6ThO zEdwJ!xop}YY2IHY}2c_Vv7c22N?#XWH>>3LJF2%9%^3d>=Nd_}G zG#ufD(`W2$T>}~m^n@S(V7AV1POmd z$fz(Q%1bY{8uYiuDy>bUKL5A2cYUtynk`*Ju-$fl-Z1dcjx*#$I`y^|gBqMA{zd-5 z%R?#D=nh6WvzFd+d+TY=Y~$b?GKZ*7f}Q+lkYZJjx2@y!2w@>5ma2*E2`K>JE1xR` zAHF=q;V!EF>@$tFdNdC~2gT{^3dWAj;a!X2_0f97-+KPaKmMgz*69a&Fjq@wcuW?i&W{xIMw+HX<_A&nycP7UQ zbqs2xVYjA$aak{-cL(l_$B|^fuygw-k=Pv_0B__`^o%1nF%|2Q;@~SO`bARNZl4A* z{K0e6^oRj`w@=5X6%wpfvK_O_AsBoIe?(W53i|1hNGx2L)14V>O=g%OEy5ZYRb0wV zwYmp<%{`I1SRRxI4EFFGI@WwZL?89t5$t5yZLZH50Qn!1TSjs)^hbg= zpPv_={Z#l5oVC4pN$2OMPItY1=7V$G3aO-DfP_|M`#i;Y1qy=Jt{eXxYp+Yr z1AL25^F_n$51LD`G#mO#mt=fiELY^1z0C$h5h^vCB{N)qV46o;!sA3J?8c&;bu83V z|MrJJvIDG~Z=P+@Vi?ZL^O#eDZ62-pq?y*wi}fw0i|_B|!xrcLK?R%TQwKo)Edk7d zlOB)5_Z8lP%iv`-gr{fOQqIbGQ&Z7nQwH@2QxUf&?yU?Pc=$Rp8~5JnT?_%lIkfb#pShka8OX9<{%!J z-NoN1%bic!mE+y~00h|U50jfr*Zs487 ziP=CoAyJv9T-mKB$g9KOt{)O!ITI2o8;GD=s4rh=C80|JW(fO;s!TyY^5tq9lmv$u z^=i?Fr46+jY0=p-lh&aKXu@%I_4DPg96EgDCy9E<$^(o%{C2D-SG_F=_;~x!l=_!3 z+i{#E_kz!a_>}NxzrUi*D-M9<|AJW}gG<24z2R44EtLJY3{YizJuyk^bj=0;0>O)u zoW(OVY_xWgy+n{A@?7|{D+>IfdJEQm$>380}#89~>d2!U&E1E3Mp|ts|Mi;Hayx;n+xQ@EFeNJ#?%X z_)o7R#`rUv(ZcD3MH|bvkuYQ_5{!@gu0`l~h|f%dnrLi}Gqc>m z6A&mk8?pyGUpe%CN=v_`x7LTKjKh3k|MnD2IZvs`^8U5H&0%>&QL!3^m^fQP&l=9 z;~X@|zdyiXZ&MNU6z*(jjIKHwLf@cus|&K^{B6nTfk!ZQPG$@sO@=GZL73M9(=Tc^st> ztN|F3!XFv4+Eiixw4tb`PSfl~^s`Y+%|qHd0jQYx`6bHkMRE=qQ5%VEy8myi@;YDKxPN!rffBn{+OFu>w(w)czx|AHE#$W{j8b=UuZGthv z6O$SW5M7ybVF{F$X7$>;2$b1AY^8yIAcq~DNU3q0O+(MAZEEixRd&6j6QSc5@ zAM^t4AZHv0s@b7VI4J&k z4ZtW&E5$eY0qRdEYiQB~l5b+di80u;x~77)>+y5J`Xt-=zE3&9*wc_?Yh#Kd5yD_5 zLX#c`5H%E+ZPo16_8qSr`iVv)*csNv>?>pD)=+Ezh#oqp(Xg$f6OmwHF(46B1BN4r z5pFG_$(Js&s)r8$_>EI2UuE@OBh=oHoenW5jtB&$w0_uk=>}?yc3OKzBFHxM+XRQ& zA-F6}=w_(3v#SS6gT0)(&VA8p!tkCE@@!hx5=-$1Mhl6F=u)4@3iz*{*LH*20woH%vn9ybES@P_z{ZC;F8)qsl+169AvH?T|-l({&@~_*339L zy{c+1DdoJq?f6vKa6k!zow!K{3ndu1=9ba1aW;uxOZAORqA2A<^|L!I`izlD!VIlP zq@xmZRGFx&LQaoij6@!3V_B*$1ZvW7bH5GUQ@zqODr z+Dx0wBx*$2jYPT^ui0ckZuF2|G*n)gEd@b?Lm>k5p)oxp@1_wte~BaI6zh0$3e1+L zwSl9$CMdezp9py4jw5IFSB^QpNvhlKbfb=Hmg!wl&~-EEB6T@86Oy#!W9r&$9^v1= zWwAOhUb-=@i+29vSmFRP;Wqsf*NORH*+Hc)H!{W@Scmp5^&arPts^^L(Zjy}nb>|h zJT@^M;U5~>v-i;9@)x#zt;gO|=#itXT6Tx7B_^Gdx0$bcry=A;#|aFy85cna=x^)L zi2lKm%#>1bb{HUy>%~n)hlMte6dFXn)G7f-#R=;)R%JIH_ zj4h=P9A=42p^}%%&Aqe?UHp_iyv|NC<2EMr;l&%8o}w;RIep5oj7h`@ROw07k1?C% z`f?P}p~l+TxbC@%W54>4g`*a!eEXrpuSuLEvsG3<_sX$L)M&7Fk>XMc*N>dtMj_Xo zds^2ud0i94o|y!&Ir4x5O$kk^rh`Rr6lmBmV_d!IkbW7Aq+JgybeB^mrWKGv)5a;2 z$iWSqo24DPar}!1W)-AYC1U!f?Zfkk=k@6|uko=4i;grchJ{v0w;EWq0r*9N)CDPi z3Oh)Za-u)AK5?2XOtTs4%0T2))xbA!;{|lT<+Y#g8@e&A2PfWqXkxZY2n{z*wYFj- zV6ISnA^~E;sab@jRoo-0B8U$f=V}{_^HJWk>(CKXaVT_43AN%&5^QK~|L7xpB- zW3^l?iLTR>)*g`?-F*Aref>M4%c1wvG7QERPnL?<10yjty+d;#hmO?N>*h&LHUDmr z&Auw|-+#w!{;rFMLTaPT?jwmdmGUG`Kx7l6V^G8NFZKLZJ}fEa$CHBcZ*&r+Ia$4;K_W9rIjxwq2!1SOpxs8OGb2=Ca*56*W!-HmV~q~VcK9vzyP)*vF( zj@<-hhKHwc6P-g-3Z2;c>t@Rcy&(w0w5)sM@)f>}VkK#F9z}jSP%ZVlI{lDSiK%ts zlTZ3MbcXk_H|KkGdIU~NB65ZPl^o(HUVw8mb=1UM%w;le(NZ&uuP4oFgrN7*`b|!& z#5S1En|_EX@9t4O<5t}1Lf`a5J$;HHa7(^Rj(WiuaV#36ahtw;%CVVIK)ut3cpqx(MQazdV;dd;q_T|tMxO0oO=a8-W@cfnChtc1*>US7s{h;?E1Nn~i z<+h-g;|e4YMqPjlZ0>io2Vq4R{GNS{)b=X@-PGHs8pKlw^}qWb3MjWfa%+ZjJ-JHD zr?}vuPWj>uEr`59al{lxlHDeF)bHHnvwmJ5X6yM9bnGEocE`%Htq3kfmjiktXhPqg zCfNUntJl27QAgvPzm8kD4w_8k)KeW2!Z)ElA#(h~-it#wXvXLd>rS!@41yCywaGPG zMGO(8U{trr{P8{JH!JrF4^mM>IJqwyIcM3*0OiFo)-5x9ggvB2ZH{)=>l_&D+#n}+ zQH+cMCn>D`NB8dY(M1&$qFPj5t4mZICv@8E1ta9g_vZ0$_k@e@-cPvDcagQz!{fEm zmX{k3zgH#SvsVKw&aR++>iGYC9<#>3$@I*#_xW89^pCS9UdhtP?d#)QW6tLydYNEu zL)L0CPI_t@?dAXR!fn*(UiMS4HebAeq51!A-Tu4kp4~wz{mZXVGEE#gJzC|-`RY%a zNB@j5)Z!8?AvF(^al;g=ccl45vn@1_MsL0iG4U5DU;a+Oj*b;_-a$oBPavYE%Iz^5 zIHn@B;(h(i+~}2>vr-z^t&vfj1shLD+8z0U2kYs-#3>qd!IgX|@mN*DC@QC*i!@*a zB(ZrE>zSW_#WDAk#_|{A*MBU;y-X+e_g8iwI(+8+Ik&(3`kSS+GQELYnLE!6Z=++# zKI*@q!@vAQJuAbw{Uyzqaa3j{ga;<68FPm>(-g*P+|zmSs-t9r9FIf0AilxH50^U> zo}@p8U=_93YS%9{*vri?RbLrz!QRgn=<4@dT6LMHsE^HrnbetUsWbh7>3Vb^WfN>c zTF6X{&Y}QwJ}^D}15yl@S~l!9m$ABW$|31L80yZ-RwZYd%wpZyM&Cc%aXcEy!#U=e9sUb6i)}r3v+5Dh=F1t9luUd8!$& zYzIAN>VUp_UeQK*MeQ6$bo)#G?VsEkIv+XOl0jzv?!qVf4|;evJkQ-ZY(>j^*)2qh z$Jl&Cr{IPkgnjxui{mTeYqf`jt1dwq^WA=38RwV!?`jqlLQfFT^E}wj3YoL$zn}lz z5Z}G@KeRl6WiVl8f5a>!V#Zpetd)QjLGD_zxA~A+GQBL9ppr?MVhVr8?bG!*^;;{c zpyUVw>WVpvT+3ys=;Be-No$qzZSBM4G7U-N<}k8bGh!A)f5YFpM*1(r4b54``F8qz zXZntvcva9L$GN?~bd4gNaYV;rMRrgEt!A^PWbkA5;0ZDWoL-^j&w5U#S^KPS@Q?P* zmO&$kgB&x((C)ELkTTptV*tt>=(z~e{Q>%+bx3!GbqxRiU7dMQm3Nx&UzQ-Nf}1eSgdId-mu14E>(lmzJ*mPSw}gvG}-?7E^L?ZfIKIGb-9eY3JXAM+eT4V@y`%Bc@IX2kDiCWr2;n zx^_LG@S`IljAE}c13IZ86dAJ9>-?l-3f5s0@I?YW?c`bVXv^?YVyD%G&yr@qgJ&HQ z_y(LfcF|(j6>u$_o(e9J?Ia7Hi1em*M@!u!Fz6M7G zqku!gJzSmrc%J0G!*`uLO=v)1k?w3PtF)6zk)G@3=ssMtP6nwQP~&K^3+G}9@yJM; z7${kM3bj>-Z`4+ZF10Xr%1qcWWe(!OKoq@C0(9c3zZ+B5HHG1ThegMN28LF3j}3zT zkl)MN0T+W}OX9}{HDkN0j6lr_ZUF`IvO|X_1y|HfY!oNq-~XN!x55}OEoiKS^+X~r z8arCjlY|8)EuX^4-_W)Vp6V^1zkm-o5C8DtBpkl73GI8q-@QM~l3sWpIkVuxHR@Tk z@q!|u@O#1e_x_HQeMPlpdJ}#Bsp#25yb}<*oLulHese*2+Xq#(hx8&X7;Xs1PIvHd z-WvND{vC%GU^I(dLITAq@7#@xAGl9yYZ`)EcMGTzQ2N+7V$E^l$Or>kE)XSB8o*IJ zJ_}BszBOVtKDinK@Bb|6^ewW>98X^U-z%Mpray>p4h1Pt8BW3i`2a>(1dMa}vhX@i zX>5WDwg^Ut2o8)q3Ys2L(9qM{_lm3;uSFwlEgb)7R?gkRk8P*1!?ZO2C+eFXoZQ>tLH%PiV1T9L}=#~4SKBiC%qtv#TIVU#ves4M~gjqw&R-ZL zKk@KU)}edf>)@+2ZwRXO&n7pE&MJx1G0GNLDez?I|7kkx(YY0GsNYFTZw6{HA^9#8 z_ERe9xMXyAAb`Y}`ot2`I=YCKwFJ9ige-UGOiXGTifr}+f(N^Moc@yo7p|g$fx?6I zPEPTX(tv(BHmxeoFaAkZ7&Q!Hy$O(mtndrhSO9tJIrl2T+D=u$Alm7oki`vWE-Y$r zv{3Y?xrRrAVJ#Y*hCGm9YSPstY1TlQ^|I{hp_{lL1s9oc4LZYcrsP>HFGVPkO`; ztM6my$RtlyldikqM(A3RPeo>-7GN!KQjIXwC~CPI;oJ0%k{uz<{WD-Djt>3aY)fn@ zkahCmO7ffHv+Kv(=OnO{nBek)-E%%RLmX=YF`)6*rK|FdwLft0y?MdBNQH=p5u!mi8U;Xh;n|If!P$S?H%`t~R*9e7$NT{fI31|X6J z8nZZ>+!qXkS&;G5r2fMI5nQ@DDe-cJ>LOxg_i%)dO^cL8J<6gwaQMO{3J}Rb*s{%{ zJ4ib00A8Rr43`H-?mmu=NlEvjVnGQI0488^zqvwTnhee?(Z}o}Hj&ehs)oTSB~bf~ zYdwpZv+@@%uX0g?j7=eT*r&%|7cE!Nkm?*5haSNT`A==kNH}lscM;NI`P=a4QEsE& z5Q(k_fzrSJbEspCvJJ+DZ4A?d8^grtL}3B1k8rCC8jySYu20!jDBcWZWCK5l zR2AcFq(Su0+F@f&e(={q>U00(cE!|G-WwH5zxa8;o;?&}J!L>Zf=n~v6GDY3qSa)O zc^0Ye!;Nlhg&7oleK9rRNvY*H0nm=y>*0N#a5xEe5lO|3CI z1`Ckire_p-<%LX1(<)vbWTIE5d39zMJFwQ~hlENOD-nrH`- zV%Zd?5TCyUf95kb;p*dv&+Tn;M8sQ~?z7L+S>(_qWFuQZM;R*>j7cP`Y!Y6+@W!+Z zE3&9CC62&@*=@YAtD2tamNlMP>_f^YCXbqI##;O$iRdK$1itb(ti}Kd%T{cLFKzRd ze<7@`a1dD)lHU9tG}9%i1UF$GaC$VYBs5drc-T2i^#Y|g79J$n0%HxW=4Kq#NlR27 zbLuoHSIL7P)4@~}l?N*WJY~XGFw+|#_Rr|0&U?#s&UQbvA!iN4AFh+hsEGYE>(lx# z|BB7TuiP|~!BHm$^^VLPbXq1uU;?+uEn@@(S&21$^Jk@?-+-XryK-0U!&~>hN^&CF z>=fB&lyUj_5IDniK%+w%NZTw5ZNLE~m6Iho{~k4W!iy1yfpR2s{^$QeJ?uX*I>|#Z zc|k~FM%U$Y`tmC`SIOzeb&M+{W_1yD2+if+ZmhEcSPJ`?uV7D@;3T<#EOkmxZiNZLNazN>R zM<=*W*g=LT9Tr7d4HSUi8n6bk95mvz^rAAHK&bM*(qP*bHd!mTrWsc)_2oF_Fk;#$ z^pkhP_Apy;6Uj?JBH2SFfvXD8{4a`r<>0j4r$rH#It4X8h@*v}P~&Q7saiyBnmU{^ z__Ah>8KYLC6^59+)RNDS| zvh&dWY8qcXCV)tOXeFiWbW5-m=Z9A;3=q)mLf2xKg;Ke^l%yS80pRZ!`bzy9HUbl; zv9^V}C1EOnJ}qB(XIHqusnbhX8tMVSA8gwSA{(Pq3{1*ycxGAosx{5T^E$PgMwFRU zZI#LG0k+pphP?uqBa9FzY{xF)W6&$5%)bHG2v`U~8FVrfjprLSs@NCxyjZ2&W1PizYp+VxV$A2j6D4kw}rS3(e4 z1iiSy;#R$91tKZ}r5}eaf5(g!ENY)NvTx)iq-O8} z+7gii)wYdQ;ot)aj*ETX#-Nd7ArODfL7`pKi9^nT*+|izsF8H0mgDYpLW$?f%_W;so8~rSh=2qPGb*v0Jtw?p_Dyvpa}3&`9a7R0AiJ~8Y$fx zvf%pk3A@S~>GZo?LD;I8V4S%wz#yc)HMSO1U^5%1;2j|4mFvYEY^^XO3^nKxDKQ~c ziBP$IhXgx$Na;7=2%5u4Q^U8W61_GSAi`Bf&F@ql4NKwOd_*5tv%UEep;1^w?KIQW z9R@eaau}(l6{sf7puF&3$@YJwMpTQ_w+lp+4@JilAPZwH} z0PadD6x+VJJ;b=daa}{F3V4rtQF-z!YwGIfo=bIiICL+tk`D0(W=89W`+!I3d15qd zp?r1C8@tuBX_s;pAtQ+o%4fqP)5PJ>JZB5a#Jl;~EUC2a*TOVbHz@^~_u$AE_>9Yy z(F)ijcx6|2pOfyv6I$Qo$}1;K2nvJ_VWw2;L66k`;qsS4Bww+4$kCH9{lAnfs5iut z9#6Ky5HTFAu46WSfTn4qU!0xcFqe>8St(V%z$4`D4T z!!bu09sR_jXnIrMq>cX#fCD;F(#Ld11LM)r6B$FrPTAUZs%?|2kzes82OK|+ejv`< z(rQ@p^z?1-6a<9@HgW#5^3X`x18~EZdFGaiF)$z=RmyU^J>%A}y$n~)O{A(wttpSQ zN>Sx^MMRqV^N{8N!opliERC7LLjhA1wW&j!8gu77hA}OKEv>6GNzDD6TrcpAEziB&(P!B&l=3MMAPOav=rxj; zZrv_o6k7?>kfemw#5`qk26U!NY4M`mr`b-^gsAcW<9*ol6)pdS|^(cprI)D~Mbvz7^<3TDxE|%^T*}fRKXF^W5GmJ-zl0_ctpl zYEk_HuGV4%GpDDZAp!(nVeMMb3s;jeWt_0Qww^(A!VLlo*H>xTCMP}LTh`d}!p0&qQn>?Ma)7gjOMe z2;bdsA~OJou$A*eOB7D@P;~0WmN$6OO@g`tHPy%f#Kc&5ld*`Zy^sZ*1lKRBmDsK@ zOJ<#HsZ&kpQv|g>4_U@SN@nJ?~iq#$Z2`V@fN6`X%Tfi!QD zx+4%2xVy-Tt?yV-fpi0G|E114ZHktw1KG_?CD7yrYi<#2WGDDAI({#8;CeT4iK^@R zqG5KTbTL3mwV~_)mve>%&Lt&A0bl}Q8Y|F?x42$?sRaCmY_cs{p{YwJ=MdoS>2#7) z$>O5?P5Ngzs;YQ&de(zgc9t|MdmwCTt71?u(cgkIW}t3X#Ln)_qQ%*<-raHbI~|pw zMA&t66AL^b*maU>Fhj!J<`HbQZulJGO}~>3szRu zHUzQJauI)|#I7f?mKEkzQY+|w$py1wTyzZ8$&uFScJBgDv=H|q%<~RL5#2xAHowFj zQm@!R_Oqc0WF=UQFzW3}N(M9l(TTq9bF)GB20D2TJNb0m9jB;#6@*4R2Q(_)j7Vdn z;XYb1gf6_pK))e23_u-BIBOA{)f7(zc_MBjvW~_ zqfN3!jju>#NqLtVBUm4R^k_B0+N*2Z=1zC(ZSbxg;vz8+<*s1)q!T&e1!b!cJzWoY zw8SMknSjXCLBME5;ddz1rHe#oSD}PsYnuXP%9h|~EN)1HODFGMx?Ea1hV>fPu-19e zy1_ajPuKo8dptHS8$<^bEaP;h+z36;8tEFjN!QrC1*(C4xP*q>qEjTqV9#<7Y8NM^ zuHL9YEhggKtaj8vV5FkOT$)>SX2HsV<2t42-~Og%c9Dj&ySn;bc>~_aDz$CpB<^Il z)zA(jPz0y$gRpj3$WpX6EYr=PlSRmn%1MJDv`R1-I5j?lmNtfnI^8GZTG-#dJtpbW zK(C_72kg--NO4#upan4(K{>mJ$q}GWf{9=jA6R&o*%?%m2wuvQG`Ss}&M(ei!Mi&= zd3T!V;mPwPppQPrMTuKoQG)sS2o>az=NuQR`LE?^#{+u#%HY`i3vzvba_?*Ben`F5J+xP~K;q`}9RC^m4D3K{t@Ztr{Y`|4Wn zJ9NoDBA|x5*M@}`+2;B<;Sw)!$6^jmxsBzMo*MwspwQ*lyzT;jUK{*W54ys5Y>2Ru)Jj`xsB$7g?X68KhbYJy&Jgnz0uvTNkCYM1aO(vsZP?{UI>Wy>ib#t*b(_Z}y3 zWzi3ZE~b`bZop_lq5%Uq!Br9>BGq+ese6p=v8vJaYSHS;3<4f5?@PnnBK3xUPhw~K@=>dGm74!0}Fl(T;Gfub>+l`UjFL75t2vZ=_^yUcHMPnMU6i&I{pv; z@~_f^c}Uyg6T)`JlYk=C+IF3U8Ri9KU@ILVc)I5B3@ik;f7Wf0vs1OHGL6I-I=XvN zVuAYJwjED+e8RHq{?j*OVpT6uQ+xbfFpE`)Q$ccGI$={~lmti#kf(0Z5*!74bVV;m zoURepQV^3noFvT9I(2qGH0dH){PtOv2l)S=oPP7>H+d^-dR{yBvv+Pzd_w{ZVNxo$ zZau*Toh5jvR)t%VGtj_LDal{c3JN7^2|!P4pWZBxE1+Wht(Y9?#TE5y)!McDf##rA zO~i_@3qa^Yai!iF=VC@SQ_Fs4@6XQN9GO^I)w}oD&(2MNx-1G1TnGQj|G_5VPF|8) zw5}~YGCi}Pw4rg+&fVQ>H??Z{Pzae{+)nhWr3{;sU5MU3bs^slVE@N{o!MNM4; z%EF7dn1vzH>YCn#D{5+2OXFc{7&E#CUZ~2dnx?(`j_%!e?6|-f-aV%yk3S&4imphp zDy`=jPENDko;=DwD5&H_u<@H;9~yV^v~UxX-^8jSP@7K@SbB!;85$c0texs3W!vRf z*VI4z9I5fR$bkZvZz11XQL}ER!?0zqq)#bPN`A~`S)dr=_5*#zACOO9tDc*;vbrZW zmuzVauGI~J=X-ith5{0WPlE}RpmoU30rh;3Ru^a^C1?ocL$_Ae;aSDJTce}JDSG=X zW$c+*xu9yk*wGyxZITwmC(|%CA>P|5+DlC<##r{#)WK?czx+z2?;b5Rs>X9YRkd|; z)AqdbPF_VNdr_}9tYkQEzIASV0*z{A?cUdq3Bx3r(=sysjl_K3bt_|MAzdazT@Q>| zTh5obzAKcZ_V<6fBy-hK^xwF}|bmE8EW|_%m_} zBEPG!&euf=>_na0hx2mpZ?=fnTV*wpdiG904gG=y zQU3@=tAonGP^;D=O;Tx{iHFWp0D1}WP5|(Y}!;g)Uk%`rNe%Kf! z3*~Q82VtwcK!HXXC4sOQ8(fMhQC|Kk4PUBpV~y6-)~}}9?K99{-&wY~rkhjlo)|cn zt!rLPY@U}kP{XWVJn#?1GyF|G^%_x6@7q5I!W6d$tJ{#P)q%*qHN=bX)rCismA(nz zin}Op|6FSVed(<|uEAc(J~A8Tt!z0WdCUlBCdowpwjO3H*9zR#vTJpLvzB-x7p0X_ z8R3Z!XGzCCb!J!zyOGgy%V0}K#+uxu!6z^C;}~U2sDBc-*r)oQ@fb~y9?Nt!aT!!b zR*WaVqNaXzBPB1CjBVY<>FRCbzP?xXzHy8s!VocJ{PO(GVTA-U^NP-20EzPy3g?GY z|N0-`TW^)+*GWo2ssXYbz2dFNK8mv@ zOgxyH!|33Px!#J*0*;o;_nyO0dgYDd@4S01GPe}ZRYzCP>u>zh61|8UcuN5kXHJZ- zu!y#?D^|iIx9!9#QyU{ML?tA9Q{!z>TjbPM?iL5nR#SCW_c^RNzmaabanS4#sn0HiN=G-D{hF=Y;B;MkfA$W^P*BfvfIl1s zl!Ogrcr|;EOIo<}DyfT8O+5r)LYyw8%d)W2_@?-oTz2BW^$j?}U~?qszM>E8^~6IEy;?(5q=vC@R3R={MXL9vPP&y57|x zw)#TfD`)>*4D1=>V0}!Y#w{_!uUk>Qk8G@Q(H3|u08U9$!L3+(L*Mh>R zXkN}PiW*0%LzztwXjeC*)}gU))k=zkuJ#lNC2wg!2c5O+=&GuDq5E|f^UweO|MdRa z#uvKx!jiIc_i`ML{}d@HiIAOB6{Zxym^8{LzL5GA0vFBKFi#GEgBaqf=t;0fR(OPC zj8v+EmkPMAy=L}4qZG*6=(4GwHDlurKB2oY($9I(?+H1fOY^`ocW zJon9?#c%Q`1>}sPcYrm6T+9Jhn3X~zUmwk9`NZJ&^`DrC$$=2+p6hx2Ex2!YkBS}t zoz{4<>Aq6l-O+`@zGMvq2U48EyKI!awW4|ReQ;JdDg22xn^g*fY3Zn`Hx}1-_8oK27bWf)FAt5nS}4j*M7W8xM7R}Oms(Z1V2+|s0 zVkD}Jjw>UYSE~6Zr&KDbZgyLf@E*zr1_nvYAQwAEx1+#noYmS{medv!$$-HovqSM1 z89PEv^~RuYa3YZqy2HQk$Wgr}^ec(e`6zhThX7Wg=pZo(3B#FA&p3SK`jB!?)b4My z6!9V-7y5)-)h@<9!Vug7k+3Q}Uj+pgXWtT6SXC{mJL<$$3qbSKX`77{Mz4}%^a#u` z_6Q%tsktGehM*(+N>J#8(c9jmZUoA=ygT3PyH2h- zP!cjKCbbg)@`;b??RucD0eC*Zn8yD%ujhWA?ZnK_*mGFDKXqJ}rcX}8s1vC@b<=fB zQ{rZi_rT#JCr&9viT7)IV#FCf)b;D1I;l%BvuvHC$$Ylu)rQ#3#{PN1$ZaRf-R3h6 z$bj%o(WV;CXdZr?MNq2j+&0dI3T_SU7&S}v4OyYb9X+8Laxde{B8CZr&_j%<6eEm| zksHk;6ypop`Xs1q}x&0&>RZhSD0hpQb zf5odkg;3O*=y=>&9!9t(8m`3mB|q2#pg&rLa0$K<=p42`NW5xqvm|wS$(`uG5_oPQ zA4!$vtMX`Y&~-`E8>Wv6`@k7EbME@k$S6%}83hNlPYaO^VBezBFP;^!GgqLnD9zik5vVYC>eK<1WfI5{KtO58iT4CIF;n8;z;)k8WWb1zyYVIL*2c zBPn80G7hprL*o(y#wn7rLKi5QYUJ|&&!+EyOmsmA-v~3|d6ln(&kpn7AZ3``|m+lkB(mX(^mDRj;8_YX>MS1^0 zq0HemI&gAmc=Czh^}zlFB#M4G$O+%@G;9P&y#u7)nWu;|30D%bo>o~$MC%CvbR0!i zdUa;Q`ffwHkNw($|g^1>pXW|Iow}-ldgRkIO`t)vtrMF&{pICdJMu zWFLD%Zy-uv#>Sth8UO`&q z8E0Sr;Uh<{kBnpJsYns`o>F|s6xJV3Ub(;3&;z9Lh#$6YPRPhFtoZS9AyDfP0>HxH z05>@skmix`=%l6Y5A);}475_G9hgmHx{294|3@qstr9e?tTw!(fb zOkq#%0S!Icqxj>&6F0`lr_IDKKwj+7&7{TIxp*fgrn%@J07qzI+QY2@B_t2wf84U| z`5(W!@7S9KHEXy1_|?}LCS;OMzYC28ykelBZjVi>R~a>5H^#Y2FYyD!duwbwj#&q& zl!YpxKUM%%7BBh1Fyi82vMvB9j21?dl?+Q@g_40DpEHkaHH1nDnDbaAcu6>e-jRgGIksS6+~|ioJMJ`=;aiAgjAo@=ow^D7J|&mw%SvtvhF`0#+rhkvGf&q5elR{obrS}V*a^t4*J*Mp z7b2hJ<^d77`oK+4!zXAdnOAc7$f-NvFw3k$0^G^;8c^`a;h9;!RJfB~lMuc;Aj<5gV>09GS5MhCqtW5+` zV_9b2@{)Z+cRoNXrb*rq41;HC-f&L(eIV(|SNm^Ie8{sH6Qp#TY4Qq~`EsBsHPotg zm<@2niCDQD@t6c~_%oUu-}0)%5fmeXdiqD+{WYpFj$S)uKjqV{uW%~|{jSDjqUB0MnZF53m znclhg$iQiM1NQ+0>bos}93I2+38*jGAB{x(!Ov}z9`rs^&4Ok#LP4sZe+7Opk>T0tfl4Q1j$S|zf>zvFeJDY zBbSh4#8VmMZ-Fhiio_9&b)4-zeGlEEl~+=}=R>hJ$aoN>SC@(kyZcX$Yj1q}51t(2 zBrrU%Mhkt*4;;Lst_v^+P>7!r_@|IN8L&PimFy|_AGDPMrFn{?W8rRidX|?5K5)rs z(b5dhhtW$#r_0N$w;eFYFb$%;6l0TQ7ng~T74-%~2q`@3!t#LL#I)7%1b_<1O~;l8 zt&a`Trct3U3I(lQIFHYmyXuv<-j>rwx@?B$i(fmV2M9yN%4no_U^b>B)tsw|8oGBKQaSf`w z2&%=h9Lv7o(F&L_iK*!)5QaQ$%kowNK81A8%%adnAK`#fQh-81`Nia5N`Xy5FY!B( zZhGHrUO~kMiiee#|L~E`n&vAEh@?PJ-#VhqDo$NCOqksgqdT1XMrF?Z`Zum#Coft4 ze%dB;11)P~xO~K$#qXqrBR)m!jNxlD#7Ir!5+lP1^(q%R6*;kj6KQ3 z9#@H7aK9?N$vxPGtBwPuoIq|?yD+=mLBlMosA?qKB)w)EIn?wCMCYSbdM7j|4U|ks zZYeAynTSjnGPy#TAWD7ZruL2uLwjAt+u*AN?#f6MlKLelKz?x}LH9X&2LfNsDO9~J zJCU3?5>w1SC@eAV$)J{PcuXa2Kmt)OBDjmw)CkHF&>i$J^at5c3F;g41mF$D-jx*O zKr<^f0vaVM$6J|QPer*MhG*68&}~^Lstbkm3#q*-v%08=Mah&+kwz1l`UA_<#I68; zK)+MuADu9lu)q2zDc!IP|4M^@BQt+E!+_-&fMC6<(h*J2#Ac3&JUJA6kw3JHbWO;5 z1y#nVEjxO}T-j^Zfg^z%3y3VTuc0!`pXU2Gdoc&gf)p&M@fT@DYC=ec(?ZfrQ%Nhtduvs+#wz$xWq9WrR1a{181%#hmc`sBLp84KbOk7Q>8n{(M=@8>5x><@Gz}>Lr6#u#zS)@SLk}nUctM zy$lVqQY5j0Pex)sRA)LP1@2RZB@9cWI7vH)&`sGBRDFw5LP8nfat&o>7eiq|VPCG{ zne8ukbh2C?HTgaN+Y6I_{2Cu)C{tLux%lQ%vKXIyZ%}&q`fs)V0`F6CYSRO?MkZ4_ zO#X>prOc5v0S(dtflHJ`Eh0R7QA(O`T{NLub?v$HzxW7x#jdxsI4oVuLo~z8(qu&& zRn;;mP#z3tk9sCi1wfvONybSgg;vb*6x~+6wx1}g0b%@6*c&cVizQXF&2+JHLu;HM zI8tg>-WWnazZP}4jEiQ_gL;ENOUIsy!F8gI5AKII8((N#uRex!Dg5e>ycM+bEKl|U ze#m1y`a$D*6&*oC^czNskV9LA&$UMn$Gf$dHc6^{Upy)Hec%wCN~T;&6@^ujSma$< z)qnoNsH!LEnf$x|F)2?)N^0@%E62d#DX~vB?kUX`fUhYuXY>d%0-#2iXQg;r%(pA4qH%GV zOfP$7;s%Mxz;yWs`fh79lGqRaS@XyS0FuD;*ptfY^+&m)Z<4Xd*Y=;b__3a}tZHy< z3bl@Jbr6tAgI zZDDlev_wR=3cMVo9Hg++4n(=FXLd0+voC@jF~Cn4k|~d-G}58SU60d)QYokAbG!#{ z>ER)ckdl!PDmmF`=s)0}0XoYvpcN#BZUdi4yJ~*+Pp1>W?>q_7YL!`e`z>}WYXu2} z-p)#)yYeJcl%!PwUEz@hRK zmf)Bt_Xq1fbSXEkuY}Lw_ww&LxDfbXQotOxDM$kxj|f#1Qx(VHiz+L3When;pnrs% zdGgfx_b%NSQ4;U=7de>JGNO@_1EcCeWpykeB5z8po9WmR(nzGOzo@(>bvWf_(lU0o z_nxKN%UraNL>+ibagzoVpbkZNyGRo^sZb{!I-gB@tmt zmZD@pU_B@eWbL+Y>!iRBjfu}lJ(Lyo(Ug*BRSvgZI&dng_=ev*)s4-;mq{%6$3Y9&&_ktM0CvZ_XpYZsE#U!9} z*awiGmYUtdqhG?BY(ae@QIN zfi_#AP74Y@?^GiwD@!XZ`k_S>$;w{iZ($BQl%;RE$(FJzWVRI5gE2ZrtP+~dyUC?h zLO$9Y01#oeejWFPLxvF zzA(Dck6`Lty?13gJjC?V;{`4+B2Zl~*^NCjf01~L3QYl~)JaDX@rEW*+kE+nPT%@y z8kTWNNBfB_C$Kzp4hu18J2IG1V|&lYoO&WQfB*mzkMcEp zDg*g0*H<#&@W2^vU8G#BQxIwl(Vmngo;<#?DbXEaML8o=Lh#%>;pf0ETMty=tH6+h zN1cYVJ2b>fUXod)WPB$ak}g6ahVRL~9-ATt$SGDXj{&j);x3>*@o*Ao$e#(7A+EO@ z&d{A3 zb`>bK#!ambM*(qb1$c}$7eg6YAh9jIOUz$a;DxA#Y=g#SKfA@Z;2xMVB|!klUs`cV zso9JR7h{ymi5ni3&SyVTw?mAvVmOPSI2;s1S(#i#FA)|{dinzZ zSrDAAf_?m{t@efM!%DQ4MG7nKb4aS9xPhvcO+aQI=6;E56CbAspU~S4TDvv&1a$|U zm@_m&ZwJ0THF#@2KC&yL0LpxJkX&`8KiipbZ@oV9Bt-A(h(fCQ)oUmZr%0ma`M?9a zn|W>Q9=Y}sEcYK+R}4Wrk50J@euKBNM+8Vc;GZ@fS`uCMvHLK zjhUI6Ht6?u z`YoyUILzP|1EiMU82iMgFGT2T{fEhif1gE>_f{N!>aLxk0j0~2q1IfybOT)6uVs-i zC$MDfLRdib>_#o7#HLF_Vw}7U@KtID(11u(*8?wIy#c-~-)aHu+iO~uOYdJ-MmpmR zfwKV7YgGUjFH8Pfk-=rRj-dbK6^_Zryf|Ue)%Pv+|B_|1lbSEM5+Ut?E6xNh+y5Rs zQ#tu;GQ3EwWl?;H^@%Hk6y|C|nRG0a666}TYKXREDWPXZ;MN}e3HV(WM}RlH68#Ju z5(tPZ()vMZ)mrPbiyDbC{STeLC@dPwzz0%HpIo@4y=I1w4_#w9tX@KEGi=B*QVUct z!DR2w=P4qkKV2ffpIbor=6S%$06WKngtsDS874)!gkJoZuQlyY?^0VapItGy7}=_z z)xgboFze5oDMV0RgGS+&5`6s0Jpu8u?*iYt_oqX@5S?<%*Lsb<7^ZZ|hIRvm(&Y4IbSByLuX2%qt_dL3}o;r?OL^i84v0>7#jUB%Jz2&7C-O=EX?47FD9 zn&GnaGcq59g_}HAcBUjdUBQx5^chF;i!ag6P`^GG5Gx;LWa>vzat-4g&@<#eP%0)) zVww8EzvkSZcTul6IX<&HzS-LbQ?2ki}Q z9*3Pz9_mJ3f=WqcpvvySJ_I*Ll2~SQQI4nq21x+F-MFDWmhx#ZQIeDv@(xotx{b&exR8`@>==a zW*jQHd90kHCU)UQ|i`3CXa7UtO z%l{xm33hQOH*}#Dv#LB8typA036>~_=kWwe52ZfdaSE4HHjC=o&Yh|V+rjg>6L+^& zAreLQm8}W1xOxT7KRp8k4U}U2eCswG>|yx%G~@e}@Rw1fxMLS;w@wqV9%T=pw#Cp( zvo?k6C24Ck1YU=aaH-GL79yg2*yOA%9r`#5OaBdhfSee_Kf9Wm_Wi%+3Qvfg2pm5w z? zqjSvZ2Ml+Q>aJ@6bizw)#6r*}HRHYuOC`GH{AO}||CtFSE?SbBSr$6eVEc%hn1v`3 z;jy6BqWUx2&Y7@qygmw(JTk8tMR?};T-QMF2_9hHx^l3ntX~x6ClB%C%T)qBqw>Mk z0K1h|OYUN0(|R(<+^ld1<&NdxpgMDoc$ixj_`>2*!jge1sM9nrNcrbV`mI>0wA;qu z-gk5qj9)sLtNgnm93`HDXdlBOGHhHqHD6C&8c_zC{!OdNU9Ol1{;-qzW2Um1DQ7eI{H!vQRy%1c|JN z>yw}1J5cHl6J&nt$zha*@)=FiWFyL8((i`H0Dzkh{GucYUnU$FK=lKZ>l5B>JwrYQ zvl1DItN^Ua#0*;4XQUGjfV;(oTNk{frebdms^&A_Tt|BT67z<%Lj;vdHl`MTzoE!%hizXh}bcODjpGz?b;E_Oa+b${oUWb->Cd>2nDJQ8z|d~aTFsxF=T;eP?Aj!7l}K9`^SMmDZbD?fT9dn;VB=$9NhYSZrKVihpIO3 zp!giNr#~nFH?&=ydq{`I2UM|AS&m0UBBXPM))st7`!k1bXj^uwV7yMql~lm<8JBR% z#wJRdDF%ND-loSA$bP9^AUX_*lY!ws2O!hB*-gbFY$kUH)dNU0ye#2S(Sk)#S$%jE z*=pu`SxmMP$Aa4IWECd*16^_y8*pJft#PDqf%PsQmvX~^TCr$c3m7SCmB+Biz*m5_ zd7}w<2?&B_k)$yiR0Gi=7z|zyVUif}vV(etW*!lVAB>F@%Ip@oviMow+sccT>|wXu zti*@2)KjNPVBMcth0_JP?WCT2%iq9QOvVE3p>JP?qerXGow0;R&f~$jc*5t!TKq08 zeUC#LJaALIhk9lM3h}EJPMhb)7veq(dZY3VC7mu|fIz}S&$sCiLDBya~_78c3>fYL+~Mdf7MWAJUb6K5O>HBD!5Jk%@o z>^=2vM5I>J6fkO6L&$Tp1X4W|d{kC9?*%JClXy7e$yQOjbgI3_m?%8H$XnI?t6w`U zyIz0eZ~r^m#MbA5VAG{e0O@L5aEsyf(t6JbTEo`M5n`1a4n%E{HZ%(yjhHLEm3Y7& z*^0D~#D`^jrE}HL#vgD_)-1S#xF8DH6=5S!m*U4!b8Ejjc-&@VXbiMbb%OWLhy*=Put`!u(DkUbplp`7zSvx_%ip}g zwrQ3Orckt-kCkhXnr4M1;gR@jnc^1568@RshK=nma2?DkBqkG+LRt0hM$llM^+?-D&*kf>44VXekU?Y%ub|^KO_bfdGV4xZi)vUy0_km zjw3#u&jML~KY>xOc3`_57#c-vMA6CK zQ1S)|7$~;{rvcG0`Eio=GRz}cm5UXJFI^s`;2UumvfHPXVF3wMG&MUkBS6FilD9-~lxPYB+Ef&>kEbpFE+#a&W;&)2`Ge-9 zR}9?Q7=%-HunKP28Xz+EMU{Qr*eak4aoQl{WIT5#`eHO(1fCWqTp^YaBmk0G1}=<1 zk$xo)QI0&LHC4@w6d}Q0YklrbTQFPMMxDTDG8^Nike9EFtPqw4s16eeQHjd$mi5yv zE0$gSc=Db-5M{^eNB}h8-7N~og+~fI~n$h!l2~0oMRo+^h7O`8^1+A!qy#g8Q{N(6cKOeeD z$wstNFuY2^=yuf!rid~D>vMA~h>|Fcf-DM%Lp|N7t7{e{7*r#{#l&Ew|0#7sCJ!@q zTt&FOlewp(99=5e;BKS2}+TCxx>wpz-Gb`E9exI6Xi-^ z*{AZC55%f z6qoL)RB8e!B!JhLzS3OwZL}M%5D!WKODEvxRKc-w8WsJ^ERhOP?E4V|Lb+uI^cm$9 z#U=1GT`;K<+J5k|l)_@vhC3Q~rbWs5HVlhUV{*@kR(Fn1qpS6~qNy27s4&H$c(n73 zbOac-16RPKfj09tx$VL|gB8!Q*t(`}m#vZ<)-0!h&OX^k(yI{m66?ljj89vHA=Sg- zBdm~kUkJ%f(2^+A`Mrs*Ok8s zxfcQ^{Fe8HMM|j>@1xqXRKI%q&bQ1@suDYZkuGQ$eprqLvC#?fzK+fib5nI@JMlZ2 za%8nx;f!nYL@$-c9tV7gbHSU0YN`FfUvK>`(0$zq7-+p zqQ3uw9~%8(zZ*MqAhjRbwrdLdxewZN@6#C=I1jKo$CN{kcSy&N+y;7*5Z&@CxTXFQ zbOXCQY!V!8q}frB>n~$mE<9RfR0!hkJDi%g&pJ}UL%4PLS1;nr1{%hNtE;T82PfC{ zC*OMfwHs`rD}tg;EkR5h+08oHLaPHttjPF~(CSccqa+J48;fjw0*6P@m!6N$$+cLO zZ;0C(_;+G;k2MP@XLRgqF&Oa|y@z5E!$|N^A*RHERL`9cJ`4dCAqO22hAUj|rez_$ zmTHZHxv-9%G8hJZGT>6;PvzeQL%Qce9AfV1cymz>W=5;U4h+ya@3}N_9RsOL6#3-g zSttiCM9)K7AY)vx%kEH`>s$m|mS`G594yX8P@Uxk6p>mYYZ$>@|ArmA$wqYy--2fC z5Gi$y)Xjp5o}QCi>=1 zVxWz5s)eab4}9<;spVE6vs_Hi6ck>8yMw&X_{3$Zl_;aKZ)if>QdqsV_1Ra)ovgw# zWnh94CcsmG+W5I5fbv%1pAp3bwzM-39G|2eFaN-yGJP|=3dbtDAcD#&KuZcYcL63- zdM!|v&%D5P)k{;x)*3TBhsx~Nzv!4eDQ5$ZCR5Hnc0n&jJ^vKsIN$z`(wU2UyG z?J`hM`sB%x*9UouvC@TWN52A13LnmQ9vs*fs)@vG6nsQ1T^UqKzKxGCTBA%odN>=2 zufaHl^AuuGnNJQsg8%4(^N2ioJ&hk;Nq?k_&g8 zTid`o4ob(7heGLDIkFszn$|u*D_I_9e-NF(^WkW+IWw7o6Lc>#=1k zsB{1IfPv`frhR;ydd*2|E>M}gW^aDQP{uXfK}-=w0Nkd*;`#6BxIs)jnAPwHU&JI* z(W#b3`PL*4fw_d6DcU49eXArhAPNZ$nbe4gG9`Dj2K3Moxyadrrb}c&ry`VIAfFrQ z1umwUAkE0Eae046!5RQWeuCuD8)T4bjd6LxWsKXK!;ka5!Zmwv@VIA(FClZ;CExOL yEE?<@`SW^lPOs*baE97dQkVH;?u=A4tarQ>gjGOXkfN_NKWf>f>GMEX*Z&6qibK2r literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/controlDict.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/controlDict.bin new file mode 100644 index 0000000000000000000000000000000000000000..7b12e9785b46c805864c290bd396a68760810ca7 GIT binary patch literal 44304 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 X6P)7`H@L@BUhn#OcAZ6rdp zDRJLYa!bcHH^50nO-(I?Tx%dT6!m*({{Qd#xGqHA_w{+sxz9QGSsqK-$0xB6N#B^` zQ{JH+Y@9uSrjDl66eb=(b;eoey zB2A%fj(R`jA}H=enDfRJI7N7!^QJnD3FJub6?Q`#e01GmbOI)bZ|1t9-O}=;16D9s zYxgA~%kDv3AlM%T*>W`0hl`lCMFq%OTxPus7E&KB=kl2%9G!iIjXxJNAYees^AaHK znT1ZlYNOM8}t zkm})$XU85d0|h_xv=%g#!{u>ODv%@?rQTe}>GXd10`nD+z zTnpeK&I)c4fWzog=X^E=g1IkAIhq@>ksCkvS6Z^?*`hvfc7^KgqaQSyHZ;Hz(UC;iDshdm{JEEop`jp=`tTicxX77=f*^YF3%0$r zU^HW%%V(k8a-3oA>yaGoX>+pb?}e8WC%$Q(KKW|*@7rQo)_&Iom**uYBm@8)qbBJl zWDi8e-y{~s-|mj-Cqs~3Z7t*86=7( zj!M$EYlEP)t~*EL_rwv1nw>LnB8aB=BHJ7YW~JeRg;@)Rw9j-El@N=8DOlF4HN**N zq`0L<@cd4SsQuoqkptHvl|&EcIp@cN^E?`US053%J9|U`-!YdUh@(qjCxm&*sXS@s z#-^|dNdB0+AN%3j`&0}txaiv0T@!;ykS%{M1|$Pk%yqtJV$;}JF=go z8Z&_Sl1kpnZG+%CsD=kNpIr1)8fNGWpcD#%M>AOhhH8vNHD&}w2R?eGM_P!zp4}{5 zcp=_t*cwq)kUfyS=y#!IJ|oH zgr!ilO;cF%UCA6rI|$Mf6>?V8*=_?s&-_u50!Ds9Q`#@T=(0d!iqQ=-NM?Q~`s&nd zdV0WbBbF9r|XCQS3O3uIP*Tn%CvFA#y+p3t|Ptn<>NtmrMZL^n$NaJ z;~nCgIT|p*cmrSGmg86|+V#y*x+y6cI1boW5b?Qy<&`yzKGA?cHt?D7_#zbw5+#B~ zC4{);;N;FOSyuPq{eM3q&i|?J-5X~Awtn?~!h@cKIc+>wYligyUZi-8Ma4 z;fX-;!^Po?My!B}SE=~+cTLRHoSfxyQVKky0C~3DTjBoxWO`;}+HeD|f1ltbqQ8XrU**3*( zgjOI+qW9tp$ni1bw5(x4k_8UjI`<2nm~z1Ao{`}@ zHObIB#AG~uRQMp)Spgp|^VohEKa&OW#71LfN0`iM^E0^}vV9>m_eBo&NC8tdcp`xg zs9zQ$G!YDa3PiE(h~fV9GNXH5er2W)-@h;Jk zgQP6&cZp&sOGj*TVElUZ?Cs0=hMl%GkIgE*y->c`B`L_OfYn7Br1Dnl=2vkVwbPYK zf&*)PAU2RtNs^^BxBXEk@Qb#!w?B6^tSDSawv)k{K8X;87s)LLRCd#{y?TFCIK}_I<+9oE=mzb_BvhK$guLC0 zQN6UOhm7zH3;D?idf{kV);N`dG?t*?#8QyV(@v@NM2hQPRXw?Mx9ZrAJF(hfVyv?V z+Mdr;B~O>_+H>wRG9e~cmft;E4O%U9EwfyUXYAm++|5Nt#3@`( zbZf(&4Y&@FLx*oBZOC_Y6-0x~IJzWFUO!8r7fT2DOklRi_4xqhIKyUI^|sKASrr`9 zcEmOXkAavVyMK0KWA51v$v6iIsT7qY>1xi<YI7~zI6;kXMahh4w`rWS z!%nDXNfu5xO4Gi{n@Q6ou7ct=_>i;vHtn-F6Jkm5!f2ETw~|{`H=X#t{d2;b1u<4T zQUV{(giy%RPEx(ro*23V@FtZamY_5&YjlDS!6mJV*;%#KA}U3M6JsHzOcbqD zRxGGfAQH0PBZ9Uz{5SOlYxSb^V*0PPu4Tuz-5JRgyUL;=&B{+IL;&z)0NS$HF8OT_&cYp*TDxTx< z4F-9eUI^*=e^nZ;T`*c#r)Y!9uM<>FZWIN)!|+0W6B|Q9fyAjD4)#O=mIfa*2t^;K zHjmKO-aY>%v0~$Q%`#-!ZKZJ-hX2{HtKrVU#yCDM>svR#r<_P<3V)95 zJRhzwvl_4?(;{(^jP6@@hH&BtvDbqTHwui;tk(`lSO62B0=9Hl%Br;FT7Z&=hN2I- z2!;=SZ6;p5we+8c84>r~gb~*wNs5f8lc7ka9UM?i!&Vu)0SzaL*S`RS&KZM$2kt&M-M`hy3G^^ggDN!OMxcV8rB_UC8zKW5&`{ps@Nv(muC zbD(GzM}V>=+_`TdX>{U@CvgMF=ITul}|5TYYMvW20{C4N2I7k(l9!c0K;MtH!?#>^!GbP~Y` z)M)vrSlQotZE^uKZuo>#VnI_$&FKnc>$1Xb!qqIMh+wZ8*e+$5zqIv*7ih4qAuO zE89{b^42y8P_oasqbnNZ2!0BG<(1FA_*W#9_w%E#sIMAd^&atVH3)Pxh;lSYFd&=R zORQ#h5_Zn2&teF>3A_1qd=~#1VGm)??B3u8QVFrr@Lc`5=LY(z)tsuRLwBF1KD~SH zb@hT?+5OMN1Tz<`dX7?7@r(%4Ah8l&0cu};f6F9AIm=7$kl^`pb#Hw7F4OgJ&|cAt zyAJ}1bTZozGBH6IawJS+2@}oz=kavs?auVhjD~Hy4pHB{d-JW&`cQ+pwRw%Afr*o% z%~Tdam!LbliJvr!CHxTlHu&wV%B)*(UvM9PfW*w#IO0oZ4YujS6_;^_6;Dl_d8a#{ z@lMctVAfa{tP(XtSK;{5Y=$X*m$G&6gcs`UVs%!tAvP>R@~$5QhOwvffko|eG5s!~ z#vR(AIFUgp_eGCB^>7&ls%R#Sb&I^93^j6nn|xb+kVqq?Bvhsx@+g5Xob?U5FzY*u z&o83>?&ld%YBziW;e5DGj?G!~9|Jfta9%UA$_i6b}W{|T7uxcg>P}l=v zT6*ape!f$n=wBHcTf1gjIJ|iGR)4rz2sC^f^gJbn2?wJvfTh)dL17@LG6;FgD0q^E zvnF5fC`OGD8Q~NXft`lbywM>^4|(1^AD%X^_f-b(ZI{yPEnUhye;%H9p-W}iEbE*w z1@Ao!!N1PNOl=K_dOecK3YB#Savj>Kpeb&8Z&Nw;x#jce?8q_xgMdSVrVyDVGd*V7 zk-345u za9!427~Y4jb1(i^)_pJkUU3=wUgf=}WuBXfn=MGc(zfDz1NX-C1Y>_f@c3KoQ`xH5o6HxSe^o7^(T2~!SRLM8ZJH6fmLBNT!zDAU3g`K?_ z)4BQ>vB&F&XLViWzxuB&hy4?=B4bx$Pfybw$!RjQNNgks~ zefshh=}MjaRITxbYT00Rj-vmj#*W5!f~GJIuMpu{_DszWm2pT@2#Ku}{shUP?Y}Ak zR0=mRZ?(f|C?rczNF3HHH!ruizd4rvbSytn^OoMBvnzdhSIOs}u`iEZo)7AIHWphR zt`%+^jt+MU_j@*fc`Yu(DmQ*$7twd%=z#A4i8v4_nDq?&MDXl#3Wb0VQY`+MGphJk z01`*m><5+fzox$8%I{G1ljFLN0Uon_`K+5E<63%gdvSXY42{vbAw%vvdMb({4=Z=| zkrLuC|K0`P7bYx|L6dRcH}!r4JZ#?mnDUVOx8j!S=y;PFJU;iLp{b&2)J6P7@SWx>s90^*C@vH>$ls7&` z%c4@OV^L+U+Z4j~kF?MG!{?9C;Hcoc^;?2p*K3AYnkj}jhwy6RS~NoyLw|m@vps&O zap>KUgqjmdAOwd-5Q4b?E{M)(I)^jDnVMI!k5AR&%*|O%<2ECz$0WUtFG1sN`N*4e zH8ql>DFoxk8?K=4D1Pzf|JHmmsp?>%bdX82isw5>rRHzUg}5U-$8k2hgooj$Pq4LW z1ho!V6$7_5{lebO)_U3aa(@aQ@6%p+ba-Vbewn)5`>?#|cS(71x|aLWMVUAjbypQ; z3GQHC&={zaUKXvvgmnKsS*go5$P`C_Ihie(p@t$4@YXsmf`&yFoS+-)8Gc8hfm`Y$ z;$xR%`MaL@zLON~9@#x|bi}{vz-m1qa-^+^8uKg8!qFn3C9k}p%2KFOT^q}?V8ya( zgDqI%;FwFb1e7vxZ}l9-s4Y>>EQNO-5Q%lrDa(EWg7)=aWcP~oK(B43p_Ijd=xpmN zT;6Ere|`h%ZykCX8{-Tz6qqFB_Kew%`A6=NQdi%tu8ih2`?Oe%{p_9BLKYk;qriTp zo)Qn-PAu;(XP0x!|0(Y)A1EJ8UoIaOFle<93~e8m%Zg|twRu3ed|WEx9|mMn2ASWu zx~~LOVGUs1#J2VV^9&gbVm+dn7crEUFDt$Nd*}YFkYjJ9V2^IR-AxWY*iq#pRGgKD zDG`1l{4z@-kY?k{ zBjE}0HIYlDh@+l;BL@M0f!+Q{N|V}4HqfP0j+(CWOCq^$Y;Js5kNzmU-&$= zFdF`-XQ{J0Sth5kEnl zXq*`CKQdVN_hP#lcP-T&a)uP_g>&X?jCQ2Z-G4n1gt#kYUOHo&r72{Bq3m2=hY}UA zH7;{mHgNgg{K#4aBFH7TU$EVBvxDAE<-;#DvTekEE`Dx)UVb>g3w}X9l>s&Zc78E7%QhRm{V*#q7MpbCgF$^N zKH+V2%egl<*YUPHzExQL$!dYrL`|{aIDz91{6|`dZ=9} zwA%5*5eYmRGG71exZ1KwDGyAl+<{!yu3Z^1omgkNI-QfJ5-P1>%d|lp6drdZf z;7^~pd}8+Tw9|y$+sfw}kFVZ&7%_N$&}q=Q8QEBM-)HJyEu*~TAIXN%JDcLYsnhGi z%#wJ(D5{6S;m)#Bo*cV|jmCgE_N^RpLK>AqLjly{NJU|3-?aH==*>N{x&={b(SNw+ z+OwyP=pzR9rZKux{y@OoABY;7XRRMYTz{yIYuk&Z#f($4r& zvQrO|83$T+QwC@Fpta*LJhwW<(VWub%HtXpT0%zwJ%V!(5E+>)yR6Yk4=^S_QDsZ< zup%eqC4VB2fOvE#<#=7RGLuKWd-;(qc<;bJci4PFsfOeUZ5e=T{> zc%r^Hc>d=DPBRqv5GfOa4&MJaufPV2mmYrcZXtdlR|s~4KpIHFfX~x@6cB62g~A3` zYvz%jYP0C8&M+(l8=f@9#$e;bE%tL^>Gtbfx!^4xy0ES$TjW`bA56G%*fi{KbL(8@ zDe_0`{jU*5jmawutffVjsoju`70g#7-^6Y>^S*A7(`yk!IvDr*3Fy{A2A|N72ObJJ zD{y-VTcR^%a}%MUAXI*T_mPIgQutCq@&p9Ad!{xW>+nJik_O`!>I?1`+@nAa{=%P& zyuWM_o5w9eCdo?kavOphc279wI#)b+f^;8sLj+ny&|j{! zw_cKB5)2EZK&m>oF9hDHUfdQtx%k22!&Q5WZFL{&J}hoq{Aly1j)T#8CwzN~e~d1? zaSl@XG`H{g!zMd@Xm0YGI|OEbK1CoI@F9O+zYGhQ-7|cMul)XSWca>|_-YKy0&|nN zqowlbLVg^BHUJs9?63UChUzT}ke=J?S|I>PR_S>U@ixlltU&b-rinoYc{PF zT{>}XZwG*7ro-VvEGbiSpJ6dTQvQ?wjiAL{v3v`D?5;Y{lrQdD++DYOagW8G*gcDT zn~KIt`SGAldRBDwZZ=|>pdsfvQakD2{`Wz(C#=!ir9rDIvw7YX0`Et8Kw|$m`Ce-? ziGJ@s|1|FDyJv)|u+=E68gT z4>JnlH2&$i`AXBa+CGTWo~#fXH0Qa)>C|vq85SFd(o*tF<6>`K4if0x<2~ItzA*R? zR4t52P?@b-d84t_h8nm1hfBl&(HRD|30y01vPOb~bPx@impj`TQaxH;frD;HY!3wl z_UxZ~OzaL-gUL^9{&K$loE(r|;ac4G{f{@z8I^9AeZk%?3K%=um#7lXqlZ&G!CRwf zMQ`j;W3c)H&*1*Ge_gM-{`=EGHM0nVWb^F9`>CsrMQX55uQGFf~3nP}WrkliaOZ6zr2ULmh77wx{C` zfKvWj4&JzFmxFRaCzQ4TYBq4r458${q$(L++OBI*y$_xl1p1z(5 zZ3@!|4{b_91LV9bLj1LE_(PBq0Mv+~8?wppMkknUFM$6^xZOno@X6NFH@s)k-YA*b zB3U@S{l}{B7J}j+AdNo!68KxNp5BelYO&WRPZTi9@m{C^Tn+GtFzw0e7=E&uoeD*{ z`=B-8a4Z<$yvW-2qp-p3#ee3e0Ms=GFJhn2GU1mU<{)@7>@bM9^T%AcP8SNiE83_) zS_tMaXH>FP9b>JhhG=6#5UtkPUACHbqU^-I&DtDO0AboH{zF{<*lDLNZ2}CW(F+^e zzMrx8&+Ga06=ZLjN36s5)ga*Xdo#hPR{;YOz!Vi&Rdyb$lF^pBH`9=!unAHw($}M9 zT>?Sz2?7g!cXI|YARisHnimN86o?264LMosZf%nS_5?2^z@7<@24M@4<;~=>Ag3*- zVQO2>_wK`NH3Rnx$F*|6}MerTg`CU*T YJQ0(+H>=S`F6+PN!5;rTFZ=m_0GXF5@Bjb+ literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame00_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame00_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..a30b8b0ae96031e65b9b5fdf937a82d5ff4456b0 GIT binary patch literal 24598 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame02_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame02_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..ee80d14d35260ac3353d40d20efbe047d3cf3f54 GIT binary patch literal 3624 zcmX9=dpy(q7hgs8B+OHE5p(J0GL@(1VX@|}5+b=vT4<`E|2d< zmlZvgWN1vxz8K{WTTeU1<{LjkzpZx!wF}13kI^+K;hVu)vG@VuY%BPRlaDH)3!P#R& z{}itxXqcMjRhz@hrdplMW0pow*9Lt|ZSb#~Bz>jDTLju!IcDx&;KpR^c9e!?N6b84 z#4h0j&uA>RYvnE1t?qSN%EZ`tjyl|&LPz%17x}$ed+PAQYg#Yb*39<2^r8tjD!gC^sr3iJTn~ew0d~LFpnG@X}i`Xg|^`#Y)3FL_1 zP017>C-vYA$Jgx4W#K^)O;qVmF{H8sF0Xwn0rLL&>Ji4i$_``SN(D;E%z=p9rZUWB%78Er^W;7_0 zo2J(dm!E`GqZ?~S1%z~xn=lgolAjblcXj0&5AmKNZ)2( zi62^c9REVZ!n0)jD0V168Y5FK(f+Lk11tf{zz;GHkOFIfjIoKag(1&SAoIyXTrU@V z>H>{}d+`gQw8a`F}uZmc@z>?V)@?A`;MISCJk(RD| zx#a8bS}V{jK*XHHjJ^w4cFD$Chm{MhMIAz$FdDuqFpXcpf5^VaFJz}%dz*ww7gn+q ztr{AD5#NlmXhn`fA42td$A( ztnP!|#@$GF>TxaYpH>>jCGM{l6#{~Vr#mHgo{Y8L;41&kXQQPjG)|ySc%5J2vQ9+1 zL6JBl&INl`#KZFhHAg18Q`{%Iv%0D7-@8k?s|XtfFJ+gp%X4D(Y7|Pb@+qkX83qql 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 QG;UKdSy_0<+K~DAFBQLo9smFU literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame03_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame03_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..dedabb1e540dac71cf5dd58f1814931d38e1a461 GIT binary patch literal 3708 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame04_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame04_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..46522b776ccd360f13fe9c8025f8dc4afb7b7f9a GIT binary patch literal 11036 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame05_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame05_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..74598cc77ab0bbc8a44dd220a2e6fc6fbd324c83 GIT binary patch literal 3855 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 Ae*gdg literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame05_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame05_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..1b28b67b3860811657ecb2d01b69d33849295dc7 GIT binary patch literal 11412 zcmb7qd3+Pswf~vXXt8YYNVdGvMv^6Cn~=(qH%ML?+wvj^t!!&#Y}!WN7ciA~L((ud zUL->jTV61bHVW7v%i$BVh9zwTB?%;i5MD?Cr+I!Uq?AX&G)+h;Fuya9e%|}{=kaWs zJNKUDdzO3dXgG$J0dSxO8cqR8FeK1wyz2%M2>c!39_~wo3pIspvV_Krx*Hz?1>iWp zefrY=ZK}`hga61saCx3ZYgrQn9pbRs3@y`#Fa2b|2{IZO0NJArpcAsxG{-oS$10dl2#QqEc#955_EFt$}JZ_-&xuXcHWAVIF4U#N-ZZa(CJQ>j$N z01~+d2bCcgcwnp(jAy)VrojET4238~3gRc&5-`kJnZS&1P$6D^mv_Ky_U}uP5O<8z z1SWBxL`g9rZveP-QIVZX6jcOU%<7$fzaL14a__NV6p9*0fLZ`M3qV^&T7YI*6-UVA zNwhQpA0?J>0H2y<*_2MwDCQXJdEtfg1$@w-)2e)-zdF-?M7*Gv0L&=s7JSw(KV_fSQaG!>Ok20}*RsJFQKQqPskhDƋ+E>aG8s&|s~NtL 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame06_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame06_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..9fd23b5672f5038187e47adca3c43d33b9f9fa5d GIT binary patch literal 3747 zcmWkx2~?BE7XF27ge8P6ED-`yK&gTi3j`q~fkx_r8&VOHN3jz1S$+j2Mj*iiLQ#R1 zsuUw62)0FqDz*ZWK%!5ISn@+fTo8WT5T~=I@W2wp*I=YG-&aZLas7Adp|k zvwz7C{53zYv0WLO`AFSMvUF-Yb(8<{F5bU#%(|bOxYJ12=Lc(B-o5v&`cj$sIAwHU z)Tp~_2%4?Pu5-v8FN>*~3#_Mb8?9aq2LF_D)FQL|Wf{G?#gsC_w)GEuiXGS%|#C2JTEJrQ1 z^e$v=oM6Cl=n3zk8McNImPKACw`uZ&`?Jv{paU4`0Q3bQBC}sT03y0o4Yz#;c*f%V z&+{ojGLYg8^Z_Hl_?GweKq6}!q%%NU&S5#MY7p05ZMJttG{E=X8`4Ie# z@MTOd|1AGJD!jk}Ud6u`Gs*Ad-{*Jpdt;_3|NMC}QZGiG(zl+?*0$P5*yr(<+hZ^OrKwH7 zU~rW#Iaq&d%~A2+@YO^de{#Whi4RA?o8V|T2ByHVFcqf3bT|%1wFGTH64bChoP|Qsg@q zCK5ed7pIY1O)tpPFVpmWX!j4F--!gh#fa z#bEGg_4ud+#b8+GKe{m8M&Fwr;owFK{?+L+k4Y$VCUeH(BTK98Hzz(fMVj`+pdOn$ zvnefu*)C-SNF5Ww3 zzT&HzQ_UO0KQ0aqEoGZ5>LoC0=aR#MCDQWM_S0U!_KtRa?3#JW@oupM^zo*7dh1bZ zwYAn-Z*4)K5jX?^;mtD>j{^39H)cB~4fxcL3vdCk;|rj}Q6F4S23`Ofa;a%TTmSVz zFpiC2BQ!`b?u{sY4P*=1LH2Mdd=@?jpNAYE9OMX>LHKSQTn<+Vo!noOtx}yfIDiRY zg6>Q3OAVHd)!4FaM;}A(-OdkI3tk$%TR*w=KmYG1OcRzk$OFCvdBRm~eKwb47GAF0 z*u$Si&7va$WOsoRlKkiF zm35Whq0hXG?Z%%hyoBt$VMK9JRd1?M?Wh;1B?6JKiI9T!M3d2J7|uFFNJPkyE%loS zFVJ}ebMwfp3nGDa?2G_yK{@TOXM1aW*ghH(m_8N_(Y}A46y|7H@1IL`77(Zes@D^3 zd%P%F;3e>>j|LCq4tr40+I`?R;5V988h`L$Mw#@SpI80K-jxTcZKCI$r|pJ441tOA z)(v-@7Bx7gFwbMjljs?A$2lUbHEhg_cD2qt2A) zOBzV2{0c?JXwGH9egTWR4VUWWi4}XrG>^2vFW{H(a5o8l1toQLy2q_ALX+9S=@jTI z^Ja90q-8X~=!4RumR`|^&pi*!Fk)F_}SqEmb*EPV) z˱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 U0*HX9ItTAwtD*!HNID1az-&HZCIA2c literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame07_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame07_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..ee0b33cd1a962652d6823995014bbb16fc92c3f8 GIT binary patch literal 10856 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 a*#{5_04V$TgexdV8Eeh<$N5RN{`)^se@dJH literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame08_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame08_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..5e731daa2d099af3360b437b1b2ecc286c558408 GIT binary patch literal 11752 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é”éëzøý](iCK«’‚ûÊ7ëYˆÚ{_~º”4¹‰…½)¢+’º�Ir^¡¯�Þ¯ï\DAŠj‚ü«H M=²Fz†¾ýêË¢ ù!ø¬‹äƒ¼OhÅ8”ïçø)X/œ‘Dâ×ÔØ$7âlªkïÞ³…hn_±Q~*¶—-)ä�¤²ƒÝY-apÂãrÉ+â_«1‹‡ìéyŠ„ŠSÔÒK^CGýÝʧ§Š�…ÊM¡ ”þë´µôÓmmPb¥ �½]ó$™Õ]2ÀÓÖýKš{¡b§â¨}{Ê(4µôÿ5ÓPv—`¿gKÔ?5Ô”A¸]´öô÷4÷~/¯´÷®Ã‘ä¡% +޾—Þ•ÑÝÄ=ⵟª¥=/eFöCñ ƒ>d7b¡ýH¿»§Å§Æ‚ÿŸ&Ÿ/Í޹k%GE`]úª^×ÑüUŽäÚ÷�ßçíh¾~‚¶°­þ´8¬|ÿ­þÉR4ú>¿AáÉtýM/Sâ²â³B"]>œ/hoõ ˆ‡ŸO¯Ñtû4ûtû¥I÷�|ÐEÅij»Qk~¦—¨kè®vò/×ë^×íÙ>/צH>ò´=äwÿX"]ÅôÞÑëp“O¾~õe}+]a§à¢äü®r´­ˆeJ˜Š]–›n.ÃùÄСölÚ“ ëõ4¨¥Í׬Ån¬x­ê,õõ”2¥£Âý…ÿÞCñ#¹(þ/³Rò´Ÿ©¥ê\¨q\þ%¢ © ÅtEÝÊ^î+Åuƒ.B´ß­éí}+º á4ýUÝÀAÞ|H.MÔ¥LÅyCÚÓ]K-‹GXï)Å|~õ�دpÜñwÙÿ/ˆUÜ_*Ú¥ûûÖ44æŠÆ¾:(êî›òGŠþƒOÙûÖôX �I²+‚)~êSvDÄi0?zÊF%N”Gæµ§ðªÒkJqaqa’Jžƒ¸Ÿ½e_A*„ª( r™T®FE˜´þp+bÆ¿zÒU4®F^`YÃè`[m_~õ§½óbd]“§÷*«(È‚¾Žã·~iU‘öãI~õ�w NßÅ•AÛ¸ÉÈÓÝ•�Ù~ëÄ•)úLYœìëïÞ²íô“ʱаÁ`ŽO'< 4ÿpJéÿaƒøÈ9› &,ü‚»öû÷¬»ô=ZP÷E4�0K'ÔíLI1,ÄØbPà˜Á3¨ƾýêï›âÁ7‚sP#—P'šU”J( R/ÕwpŤ¹æÉVµpÆ ¸•oP? jW`¢~õuý„xC"ˆ/‡JºªÈÓJ½ö +zP_ï%_šˆy]ñL[MAÖ²¾ýë oŠÖ±�Á=©X!¸|Bãß»2±ÜzX·)äMûÖÝ­ïºÑÝ2 __½m†9oÁûÖ�£*ûÕ¶1ûÖuäuõUôƒ÷ª!o^ ¸½Eû÷ïß¿~ýû÷à \ No newline at end of file diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame09_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame09_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..bcef5f4c6286366ca8360835d04f0028bd33cda4 GIT binary patch literal 2142 zcmXAqL1(LF4A3>S&A#abMulrcV^Bx|2gxYbLM6#DmgiM z1t~`{%~_>7Dg&=n+D<|%Doeq8ZHu2%WI4k-YpA#)RcLliw5sw`CBrBY#sgFmmS%5T zr2@P7s!e;0CnzLM<=Xe1aK%%rDA34~XDiAFol_-Wh`-z)?{9!GEqv^;1WwW#RsPqI@#((+vtw(cj zU;H|`&WE#Ehg!Z=tghe$zLL|l=E)t#&;GgbqI=!f`siRH+mXm0V~l$uKz?(Y@Ty3S zQ&A#Lk?o}IyBp18=PS85?Z3JmYeA?-Qq+Ubp8q`HC7bP`&RuW1TSj0~@d4X9zR=(2> zL#EQALe2g>sEfOD^Hm7VOub#k%Re9EaPzm3C&zOh=556k#8m!g}>K1{8208CBb85V{EC3a4 zJ4DPz6bKNbS+Vl!k=H1fL3nEMlcpqVW5yT@LxD1Wo*1&7zEJZtnmhAjy^#ry>KkK% zeMhlopD=)gt^xy7_S!`&sETN_UTWXW35I1#nD3K?j+wz_z;2!~)@<4tg+j8TF=kQX zzdiA!i3@H4l=Y-kH>U!fQ^zL=YD`|QF}yjah1?p}i?+#8Sq|wnOTW)fovtR#kF`js z@Fj0NN5h7hsx-4A##!^V*Vr)=J46+kpOB_9v?ROK5;$ZbQ$>x6u4Vm%%G-mO!Jwtk zTi}hp$|u}(&ON^gs;wWa;P;gI?3y2lONCYqaobM*{;Fr3zdfdYXF_qOOXEF&=lZw))I@PDS9Oxwis5sM!PAMvHe(Jm$i|W@oelR zS#grLY)<$uev`wiaZ%I2M~tZK5L4bc;7g!-sFN0VvILd-9Y-(qs>Xr3pphfLzj0Ec ziED*9TqGP06}rlOdhx}eHLVZ@XvCJ*@EfTrO-O=jKFt{Zn4&A`+VhKM_MaY zRb}JGukPP74)WyWw$y|uu8;nabrqjoE>kNFZjoY^FY5_hXvH{b+nvi`iw!MiaZXE% z<;7T{8xxy^m${MPbwnK>?LvU1$beu?69}}7qAvu$U7r2JtjnJPi%M|2$X`a2Su|v? jCd-LQsFW{dCa3x4r7KL@?>T-u9KM3ulV&n%iF5x0P)z&B literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame10_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame10_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..77f673f7a79085c2ce6313e62619b9e5a40ead76 GIT binary patch literal 1090 zcmV-I1ikxy{rmUt-@ktS`_9A^>!|j9_d}~{p#xS8@4OY-mkt{UO@{V zyW5iUcF0W7j%cGHSWlR7gdKX`_I>z_ZZDV3wYfoFUvXmlbp^xstfg-6dICY*XgO*-j!WH=?HUOC5eISKHNI_& zzbNAmoSR>>>F3TOuOQ{6j8|#O-z$b0Ofs_+Upo6247vy-TDBOxu($7Ae90pc7+xgB&@)5*8?A%PVBmxgZ)=Aj}PTx?&||E6A~ zstoQllC~iCpQ`CUPf%Bo`^b4{ZpB4LLgo-%uc%vI#q!iHzJq-V=f5O30Jr9T^_2l~(8ADusBU8V8XK2E z_CG9?cTm2!Sic;hx6!WJ{fIet3+C7FSaQ%@x=Qg2`LCoPuOWjyAD8o8wvV~UzAtIE zz4Ar#AmyaHaxp)8!unjUm6oRC>~x*FhF4rPNIf6s<)!^l%;-KOpRxNsoXYHj7z*W- z``yw_F>J-(v2QIVFdO%*?pa^NlDvkzifSk2rR9E(gZJO9rRpyCL5v83`g(hWP``@d z7c(HU&}Yn}=Dwi`Na0^D2)}yDz4PVw0xN-C$Ic{`-%!o_)}6x&-lCGN!aQJv61+cp z()yYj%Vi5yj2Cc@`l1W8AH8eKzJ)8wsv8*=`6d1L?^-~e&O!6#Udk&3{IrWUp0D662i+2gVUS$?^t`nA7m24!0Uw;NA?OT!4z(aB*irn z+_o`}8rSbw^3nd!M|p89M=!Y0`aSm;SM6|yw@4%R`v~VGmaIYYvpS?{A&|D|Ehp6b{>2k!vjATb%BO~{zBw7rG zdWbg>iO)F!|?q@R?Yp%+m501v+UH4C%e)TV-pr9-6uCIjPqeB~~ zGe3HgfjMY>1JpI#WpK9P`_y%-`kNT(1;fhj-r_|TTt9k_wRhd#pswORSjyr0>8Q$4 z#9gEJso`&4sknai>()Mk;rrBmqbXvp=B^wsmtE2F$M0UfS6#^O-lFaY?@?EuzkdDu I_wV1oe$!7=Hvj+t literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame10_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame10_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..12f1b76a8345d8fdadb44096a3e2cc3c7495b8da GIT binary patch literal 1548 zcmXApKZspL6vn@qd-rAb?RA|siL55Vy!AmAs}~e8Dl)_rE-E}k)N&=pq%ft0HlDDH ztc9|yDYVJT`DO7yUXO*m)>Lo>dW84v@7>qF43k8wK zF^`xW$+DrzJPKW|GgilPWD!^&@qXA~h_tFy%yg`Xpe^tSpc^lHUEtL|yOVcc_-Xv@ z;io#7ZvEM|M@L7CYtp}Iu9G#Yw8SIN1QU)>sK4%R-JEV6JaKwsR$PJ@m~P*4qiXYs zy<4y9vc}K;6**&|T;QQ>Q5zdo_?Yb-OfGyrefjCH`_Mq$4)y%^A73dLU)Dy_onNm1 zJ$gSL>&)>Qlyw#b*;cI$6>U|W)LScG)}cnBKKA>q$$A(i(pK8Du;gJ)CDrkO2UgwOdaEhck)W(A)-8^fB+wC6weRy z6e4rJsjebgfnQc1Yk>pQh==76)z!fY;pq5)fj%-%BFf_w3wcuA^h=|-<{fFQ`t@1i z)dIY8oRlzaN)RanJv_Xfa-<3LBlXq-_WKKbW4uJ>@? z-m5{S0|*!BmPC{$70q-r=N#3AYhm^Oy0%X(?|`ST)~iC1mO!S5s=eLIeGibnOR%Am zCrr{U^uwhOJ^Zyj;2*-rPLX}iC2Cji$j0S^=Jb^}UU_6jHL7}SZ*QBDrw^z( zD50u5#=;$%i+nuY$bHLSP* literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame11_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame11_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..06331afdb37329f72ef8e1d67ecaf4d0169952f4 GIT binary patch literal 1390 zcmV-!1(EuC!>8|7boCEU-o1M3r|(zv{p;0TKYFL9?^pEo>(m_&-o1MDheP+TUcF(` z9-qB>_3Hkgzk0TY_3P9n4bM;BuEsz^?DgCvzk0vmf@h3x zTvy+{Uw!QcG8OmB0qc0s{a;9WL(-0ze2`-zA+KM3YeRb)datuRUvp?Kbc*}(&t&i; z)DfY_K>h0OTw_7(g6A*4!K&zJJ*A9A%R^u?gB8Y~y;&wS7D|f6!HA<8dKp1Ji+&GL z4G+lul1NYKDCO9+H{mXAOlkYpUed=%k_#t6bBX(g$z;fa{6E#7ry$ftxzw^6h~1GT z8}yR=5q$**!`aVI+0ycWWJv@Y?1EVo*EuYf8wq6ZR>NiXhBB?{=qwCnWWJJa^WcLS zUgNxe^>?yQjEmVM2!rF;7j=E3dd8w>qTi3kThvX%YxbAgX(-x%WFI6Fx%{8ArNw}Y z_93x1i?1tl?^A`31c7Zey69XV@SP_S^5s*N!%Yd$U`i;2v;+p#&=0sX#ZjfE~56YLV`-( zzjH*tvm(DkTKs3FJi5+921_V{w}^1vOl#R;U$OE>+z}A_cal^|eu?^?lv3vn;tTY5 z=_^#C4AfymS!m`q)MG&+O1@p^$tE-N^*udHew7XSZ;g!to&nL+W9_Fs?O2)lhf z^y{a4B)?Mz_Vf3vqeyX~P+zJ+jSiodiv=ghgFZ+ha#XvO2eOO3ZuYThFV@QD{p%TK zka|4j9_zYw8dzWAEMetSh9$1eWJq9#`_pBpGEHC5B zHiF2R*Oh8Ae39cxAg~|3WsN6|DOpG)%Fc$T<#w_-HT%|4q!Voqx+<}yl*UBkJ$wCqCi6^cIwdq_H6m|?`>H)9`@A?3 z&gwK>^n)6wQKs*>h$Wu)-@Rq0(U8<0^if}g!# z=r7_-bEx4WT8=o@G)Su{JprbchBs1ClO12i)N{j+>7dkg!;z@&f?6)$U44oB)(~Z$ zLF43u8r26c!q3$e04VIYbl=E~e_Qrn2yw0T9ws|mM&G?*@Jn`Ux*ns{i(MLH#2*bu z#13^B=DXJmW}(?*w-)Kwf$Jeo~^Nu-mCa4^p!@C zey?7jv5(%U_+8GQ;M4U}M+l&jF3Eo?T0|9t_o@C8FYtN#(9==Cf(d@Lgc2y=_#pUJ z38>)(96x%J{cp<}@=U*9*ALkww5@w9Nf-Di_#pHWFD*C0`_#}n{&&DbLv26-e?G|Q z0}fl%GSk0$mm3NVzg3Nf>zR;tR^HSSr}iQEKYEwt@Ihcau`Rwz{s=x#-lKun^l`CJ ze4eAREWTcWU&{WM!TQ?Qc2+k!zozvyiS*e>yZ9utoNo$c*%165y-`6g*LGI74_>MH wKWF;2_ImXlf%1O!zBWcSO@Zhr5g)P-XYbyj_(I;jLF_+%{rmUt-@ktS_^h?t6#xJL literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame11_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame11_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..f64af8ef7305baa811e02670b4ba01dbd74a307d GIT binary patch literal 2028 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 T-lFagxL?11{rmUt-@ktRSGK0G literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame12_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame12_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..f44f073c7d0b4459365d7345c9dc457c40773d0b GIT binary patch literal 1806 zcmXYx&ud*p6vw|aZ}M`J_fqF2vA(z((g-nBxv3ROK^W5@PjK;YA$1op6`=&IAh?og zO>5CEUIXrfxKMDBRqb*FcOnEA-DIH){{UCwM)Y%Tz4yI4Gv~+meCM1qBNrsEtgk(*=b?!7)i;}6;^(1nkE+mRq>4K z>k{F^08Nc*49MlyG+*)mR88w_4@*JEQLe~u5;Ee_STvq1Fgumnta7&}?c^uA9&dcK z+tZ_ivkOc4jG=$`?Z*daPtKRT@mhAut3kSmqmq;|$@OokIj-Y}>u(+%ZI0I4 zL`JlD%jjyj!xY>1r5x_K>3940+t1yd=jz=4>%WT4!2Qq}nx&-_%Wo-P{&4f>M~`m4 z^4A}34E3VA1xXtra4XHV_cGWpD$3e;_h@b5VgI=I-pNSOvJfCf4v6llAOecg7@P3z zFOTcCv!bd${bU==>(!Uyl&}uEuy&+$qbK*VpXe0FgWsNPeYVDa3TY#O86#2wy2%b} z)wP^K`{z&j>{jJp#wrUyFE)bB$n_K&H9?CR5pJQxBugY{NT~c!eFV8k$D=oPirTcV zXXojjp;eI#LEn4_%(?;sXw95;2#!;RylT;kytjTfmKMmCN!3Cmk7OI6dGeID#Z3KI z=><3-nR%2L_9Uk$u?h4u(Axm#lb14o~Fer%!3-%)k}~5^bKJ!l8_t^1R?&=G%A$=9rNSObgL)$d%FR=F6VG&`L6F z%hv>HKPfFguvJdR4OOb^ikFbM|IZtn1>J3O+6=K7=B_nMH8R24q{~Q?tyWeyPHcy_ z3`i-V%7~3g6@XWZ#mHBoi&0`;id_Xn$U)aw>VZj7YQcRl@7;!xO^MGC@g8TvI>8Y> zmz*;z>rMRBulM~hVbrMf^*a!Q@b?}!jFRRIa?ssGBd7Dy+TpSTaaY@&@ApkJ313^( zvItT-WOlI^u*g8$X<&kyP>K%f>t#>cW>4t)8goH zx7<()%e8Gob)(n-3LB(?rnBq%>6b~;WEJ?!rq^oaGYHmb)-p=-tlv?qM9Zn^*@^uI zmd{Y^DXJHZYM;qV9ANAz7KdLtfm7FM-1RK`<7(X!{%xuPxlukG0bi;wuB)x(VP;s? zFjMv7B3f3)5@e%N0)ATHerIln55CmBVFSu6xyUS!{BBD>5lEZG1Pg2%$C&t9>Trhv QYP~?cd$<$Ca$|-559004`Tzg` literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame13_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame13_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..4f7042168f74a889905f0d54ec9e0be6ee9ff91b GIT binary patch literal 1403 zcmV->1%{rmUt-@ktS`_;ChKYF^?i0|I5_^TQlhwoQPa);)6_39pewknuZ}7HKYG8sL2(ji)VM*8q^i;j!!<5a$rT05e;3IIc%IRC zuX~`qog|7N`>ic~W%!#an)~;xtX?{{I=Tyl7!VX*(OXP)nNK8rC6qE+y{7i0wec^6 zo-QA=>FOy@P<+X+AwlwqDyNztGTK1088CeKgx!R2M9Q1b+2*%#YnvKJtsE zSQoc`i6zU${^!SBy}#?*-K7Wdl2_B;Ji2Neb=U0rdWYt^l2|hB#1L)n8km}xx47f4 zcIKu%;}SfSB%x8)(@KkI?&2Qt2YCITPjT2iko-s>&zTOULLb-ki^h3IaoDRB{tZl< z+`enZtCq4oGY2!2W+ zL~w7(`kGX9@3rH!!XjHZ!uE$9sRTql=B8{Lshz2x=(z2Fu?Nh9wR~2P%56v1E42`I zv~X|9`kG#$ucEj?a_A*UMf#nu9ka50U$4$x9T&CZsIl9x{vxn05JbC)2WW@&xJe~T z!ymm_Uq@JWMHeUKaE1)!Xe3(2pp`0OFZk`~E-~B_!iU+jYD{+*>Sc^)5TA8prV{gJ92z1d)+f=SP&<9@j#gF{}ic+D-sUYn(9~yU_^C}<5Nld z);RAUuW;P)ciI0EFNjs#UnI6K><8~y(9n073M(4;=%cI0dAl(jm{4kIzk0&Hive;T z$jk1fKRGcPObKW%UHjHjF)DPTKQ=qv^7dAhYHB3zBpt;4>ni#T36iLwfy!l4Wj3S0 zrH=c7TT@m0){?2?ze2egX;_nLYieuy1W}2#ef!p)_s#LH$<)}DRi%9$#b$ipXCv*# zk|zD@I`2T8zlZS+*$L!3;Q>Z=lD5m0}SU zVS?1#PbWMQ2{kt`O5jby2agDYnXo5g!+Xdpzi+>K%fI;3Jr)>RQ1?YNkT6Rp=aMluI=K_2xL;l-wu^_u_>}N(yx7jo|G5FyUADPq8?Wy zY=>fX%#>p4x4*VR|hRuA5&@hcRj>7i{UV^c#A)&PdF zVUL)euphlo;5$`fNm!az7L!oH1zcm_3D4KpRS6_9$gK9 z{hb`MwWqjx^&g;q^&NSZ896Tauq7a|D868R^&Mh*^*6@{?@;)K)+euS2k%ite*OFR J@87?E{rHmJ-w^-+ literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame13_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame13_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..95f7c6237964785b8330d06d493fc7e9210db07a GIT binary patch literal 2186 zcmXYyzl&W(6vw|aZ}M{U^0JwY8+j7J+090lu#p?JNMSK7OI%T8iv%nzUXVx-g;fNx zJ24R+Xt5DR3yW8Xs3>eKVqxJ`qlHy$Y|^Y<%Ka1koE!JY+&gE^Ip6R3HCIJ7C@4y% zEE}kiniQwvC#Wq6K`A--d5V4-R7_gl4aIDxvLTI;CjPal=3P~jv1>3WEoN1kv}|0I z0kSPWaerC29XKptnxzt|vb9nvJR~zj1v-{KZpQP!XSdG0@X2Rq zj-a3Spko?;a8bi1TaJ$Y{>|6htMSUC?eNs?9~8HUv%(`6HCZbw(30-@GQedw`ss~R zcUGT&>dPA9U&~1Kf;cn zgq+oV9YUjO=c7yAuOW^Vwr^ZXu{qqdYebX|P~Ak^!cv>oP__Lkt-ko-F8nsyo?g4YRoYXuFSzJ{B=deOu%{N%u>lb&&1T*$c#Rq>DH2c7HrmDO z?sTfAE&5h-;l)p^9uxkDR-2|tE&5=>(%k%)hYX5ALj{=F!e)mx3R7Q zMi0>>wp{>W>p`jAa*Lv}a8B#<-n=dTsOBsQEv&g=-(Nj{z*pIG8$>v;<1H1#ODbiB zeU=S-jwnT0RB8k&rCtn}&LF3bxtpn6GZ z*ZbT+jns6CW)nx64Aj!bhvGIJrMkzWA+N=>6!L;k+CXO+nU9VcWg}^!ROy2-pG@AY zqn)){tCvp~N1ZM-It(uWy+12yF`yX}qU0JwYMt^1)vH=O;srvf)rb&1BPM~LnpXEh z%C&Hs=meNzo$=J@SJe8xVpAE6t`MMWc&VslIZ(hG&0lE79>KUpF2F*#D1fv}ZeVmD z9?f6)OTS4v`oFEgsnY>#b=B)Qvg><%6V%Rh94oJY^GN{x4#)#Np%Kbwb?6|@FCCXI zyAQb6D9x0IvIW^;m~rzOWF7~zLRpE80ked=^-6RtIb=k=W-;@1)6oIL!##n;#@=;l ziyLO#bYb+u{FZ0NPMp}G(=s_$oW?r!$*;;S2HQ$2e0h&nc~^qhUk9`p{?Bu6{w=}@ zb?x=g5hrFXm6uTWTT-mb49mEIFx zy?gwA^?n6hd8rbGk6yaz`tFeQ>fr>LQDe=$df%t(xnU5MjTHRr&PI8<;;iK7_Y-2_pZf5`d+7|8O(Y_A75&E_ZQUvrcgq@<4J*W20b(O zbiJ{4lnW#Wx>gq4$vc)(Hfzx}T^aU(L%)`k|To$xI)q=|wJO zc0rGAev-wg_pmUbth93$%w5}*1g`1aw3jY|wNKRa^)GKBzf6Mg z@G)IECoA-LHv=2>RH3EnO4=aDx=(P611H6C1|sh)7oz;iG4C&^LQ*(a%hdjU^_6=A zD*J&Iz^>!Ck>lS=?}3^7)^U%~;ak*L8SW9vpo3HW`_`A#&`6bKvW2PcD<`-|eFX&` z{p(&{J`O$-sce|Ywmup-HdO!Le)XmF{^ub1@~eh2+ateaepv^62Q$z?^#!-{#Yaux2nF$ zeU(0_3~r>mk5md`{ZKwy41DQe^+6E)ckfsHtoEoZ#u}j5`RE}LRn`+2qOT*C5ih24 zk4$CrSERu#EU?HuIlq4Oe{OriA7m24!0Uw;NA?OT!4$G_x}r>2`VoyLK8~n98rSbw z^3ndz$nyhSu^hhRLvyQ-usyC2*69S)e)a3r6Fb$^zk1sG8~3ei2d`0B$M04Csi9-{ zs$psCUiB>;2(DVZf{uRmG%G=YRXxl-M@JV+mL!ab>%?S!^(2cRx$dY&KAH|%OPG4L z;ao1@e)S|5L44KbKBhjeGf)N^?0swmpB;_DybT{X{pwqtLAd(z=sLdVf{hGroXqtE z82#!>1m&Uh4^Y=}mBQPH?@_?y6a<3d<#%s!B8x5`(B7iC)%SO(E4YuAGPr+7Et-tG zA}-PU)bO{jRNOy$_3Ix&aQ*5&(Uh@Qb5{-*%dY78WB0FKtFGjC?@@OH_o%DS-@ktS J`}gnPzW^9Cf;RvF literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame14_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame14_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..35f05f73c111b55bd878fde6b654ce97dcec11f3 GIT binary patch literal 1748 zcmXApPly~v6vn@*uFQ63+oTpYGHMRB;}SzqXe5Y6kTNbpP$6m*@#2X8atU~epch|E zkWqpxF$X;e$t{S7AnHwMB}zO98}J}3VlG1PwBEew?@gwstE*nU_r34^sZ!OmVUi^? zHS+HuK21?lD}I}*O({7ryUD6zp^Dx(O-&XPLvLD=e=yFx+nSmD^TSs}{=Y21t*fZ&l zrrxA0z|9mB*hQ)tXNiY-s~e~hA@h9mu^+qNZ~mzZ>&N~|Y5TiV+aJzPO8-q}>#JK) z#ce#!+wO=GEZHyTj$K?ocHz*zXsxwF<0F4Z z&wxXPfl#8R)9Pp+7}6MQeqrsAPuHJ%=&L6~Lr^BWJSJbRqBJ__*4say`e$@nqwx`G zzenPESVzIDl+1PW{rkxhtCyBPuLwF8UtHe2V>0w8#Hvedo;QY7HK)U@t??#$+h70s zhwcW?JXyI5o=8&-PRk4<9Oj|FPjM*mKP_Fbc2_gB{5r$IOXW0TtY<=?q$Cr{a0Ewj)TX!ns4b-OEeWeR~pT0#BIaLDaZ^&DMfq#_TC3qjfg#`P2s zM^o*v8E|orhVaqOql-AOwHnR}XbcHL#q-^eqh1$t=ECX1EOca_?R0qeSy+wJq$(8Y z1(4|n4OVaEVW>f)Z#@@emI@!_dj$=Z1IYL}HI+;1WUJ84xkR1Q>&rGS?>771c=74G zdigyzm~>-iYDX9WT4cy`MI4|Pp|B3B`_-x@C=r=y%!IOTuHZoV6Qj?)6^$l?5-I6D zLi!2&MsYvBeeD|AY5F|~I#}>Wb(ZBi9zqK|`SB*(tv+*W z?MV#jcKdoqQxweU0^{uN1#64zYvmN~Y7c4C44lAbv*<6Zt*WW50p=<2GqE^jTP&6q zb@RhE=e2?|?>uJ7`Qkos_yjoG$R&PevNX1w3mk<{|AFU B$=m<{ literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame15_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame15_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..6b33553e250854ca5c869f09a05b46f83163e157 GIT binary patch literal 1277 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 W{pvo{{pv2@e*OFR@87?E{rm7kBAreE literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame16_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame16_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..504ad82a7f7365a22ac3c4fff469d31f84eaf983 GIT binary patch literal 1814 zcmXApzi(Vc5XZl}x4yh2XS3&6!5|c^oeLz$k={X&QbcI;qi}(cMS)0XDJdd35f)IR zad03cDIBU$AQ?mforQ!+`3f3@h%6*%+)(fbq(TS^_{{m8?!Dca?|f%wXJ!?W6jfzc zq@=9nY^qVSQl3Sz=vq6EN+vZ~4HzU?6hxI>y(ylc0$|~DEN*LvSmhF8hx;?NWNOvZ zJnL#jEKZ|@&>`kzxvC+hsf#1T($lniSzeR3SrKVM)CF*}&=* z<*3dh-VC*F>nZXN)fJ#k4Vq5cb~ej_P%^*99Y=GUw#x3j0B-M}?$e#8FKYMJ{>kaO z&!>Oqt;e_ae?Q*Fso}@^00NyG+<@C7OaqU%w3Vo0Ro{m9zhB$m{q42sNN#p-s3ipr zlp6KOVCCEEciT^N``K>l`o&-IO%Q=NOu19`+{{nb?EVjHKkx3Yz53Ump?&>J-Q}b& zwCI4i*OfOE$6%{QS3i61@WcLg@7m4~eRfb1&uDViLCHiJas#*0H@`gY#)<$sZ9vr9 znHudJuHszQ9?sT;>)|`nrHk6bZ1>%N-|wC9Jg8bmK*i3>>39x%UpbgwP_YzwqBSZ+`6N{uPD>CSQ#SphddKTs*hPDz_JAf8h}1- z@+v2%^c9sy(pSd5vmkLPQEa@^3J#aTcd-~~%^_MZue;?|dNpT3x z1^y1EI9wUBmS`I;lTP)c_4Ue54Q_lu!`(P21klnkr;(wUUmbIC;4^JGSGY0 zD>f5!Tys~ayr%7j#^7a7nr1nD#W6vry2SX%ynFH!qC?gE4|rVEoB#j- literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame17_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame17_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..545abd610422d4f2a0f136a0dcc9a0a0a569bf98 GIT binary patch literal 1430 zcmV;H1!?+z{rmUt-@ktS`_;ChKYF^?i0@vzr5E?_R{Th=AH7{E${(5g)!(m#7JB&G z*BzPr)%V{1wDI>$r%zkg$Ljh+(jJ{$lo<|aAm6QKAZuyuefv6F#K?3(f;|uKI(S%og;Y0g5o64knaZHf>mh*xf1Il_>km0y+dDq(w3J*LGnZKWIaKj7s&=g z+IvQ-enaL%?nCk`1%W0-5MPK(UsGU^$x_OuzWtpqJKYV!3^^~tqokl*L+Xs)$E zTVD4;kr0PfhgFO9$cdFBA}UK4J-_gWS=nHB75qz9*^;022X$54ET^_PonPSKd*Nc%Ki4W!cRwoa0|avNP?HBE9i14 z5MYr(s?x7js=z@8d`K=&#VTi_q3vn1C~_#FDJA7Pt#8!QJxh^C`ru`tHQ#6aNWLLg zaeS+4kJ0QG*bm;Z$fUl(82ugb4-&ZGQEIeFg7y85F28DX0xx5li<{ zo3bV&NqtcU2p8{JSLIGY^IFKK#Myh1P>v0iJqK|=ddj|nRS8s3P#T$3o8(mFRb*A< zRzG^vBDdM~qT6^C&#=9pX!y?6{eI3FJ$YoYX+l?em`_^^e z1eOUrKZtNPC!jkcPj*$wMPIt{4y$rZi%bse>+jyO?erNIvaCv~t0LPX-8`KB%So!= zk#T`HRm0;T=4>#SB_Ko<-?!hrW#9a1o{Lq+ls(Z+s^yV%pH+jyM@Q>t!BNDZYehKYL!3dp`#4bOJvk+is^a!vhcugX2-nPia`mO3aretF;uaDlg z$j4jo@crvf(UpDs)^+4{^;Ps0gZHXDO2nQP(pEJzF%4h{wO?i@tOxH?Lcai(k84<3 zO+y3~fdr*wm6q%Z87+zq^n$PwP{b55`_z||{8_g%E)SoMp z9*^`6ydaW#7~?OJzG@GN`_y+Wb;V^NzEJDLbY4DYUn=3N_>T|hYbhCIW})S7`L-n- zliB!xbhVImEW2786C)R kqWOXQ)P1AZslGTrdWXa<>(mzm_o$*je*OFR@87?E{DS}BSO5S3 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame17_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame17_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..4f3da33c360228a3e42c93e5aba180db2d395906 GIT binary patch literal 2234 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 K`}gnPzkd7#Crh0G literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame18_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame18_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..6c1c456b006d80d673814b862a2121d89d19c882 GIT binary patch literal 1534 zcmXAp&ud&&6vw~!zMJIbWvZ_xmDYxGGaYCU7mpIE78e&gioptlsOWCQ8eOP67rOCC zBaEV;X%{XOvXtN=h+E<5LQoJ=a94zGgyJgx1^PLY8Sb0+&iS72`JNwdRI-v}CIxP+ zms(YBtEFPfZIDzKr5eX+>A9?z9aEl`21VvTE)kiOM&K%}5^TkfdnAD1`jB zyM24Kz4z#uVK--lG#pnAy~10?Fxjx3@|NCEbM>F-U2_`JM-7L?c?@8%+1|#Jw?@xj z`MwX8itO-abK73avJeLX_Smzv#OC+oPX5<<&yoi*$<5g?mN1`@HW8O02y{ect4rNrQlEADNFI8p zQD2c^UjW)TcSHzer=JNtIu}Vunpe5&)Ve{TfTRd$Ql*cpl83Ga0vSAVJ6IKjN?%e1r4YSwZDYSaR<17pI09~eedzPN}i{M0h_VSgn7#B{i>MF&JI-sojcjft>v4zo87bYHA@R5 zVrEir_uIZN#C}2yB5lbqe}|vs6_rWvz`L1~_Psf4Ds&+mj$PeYu_k{|oq6+>>*r`9 zuAb;SQxc7N;0>ManXWSZ=h+*J2;#6Mb$$H4<0^q_EUU9m+}u6xYee zr_Mm3gpFUo0D2um*=oTj>94?jPJ`K;r~y$k1U!5mP@HxIdg8>?h-Vzr02<@Wq*G29Hi$HMT(R@D?rKZV Xg~jog`C+PP{LcT_*k?V+ms0)*av`uY literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame19_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame19_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..f6d278779ca5f3d45b0fe9c02918dc30a65f3cfb GIT binary patch literal 1238 zcmV;{1S$J^!>8|7boCEU-o1M3r|(zv{p;0TKYFL9?^pEo>(m_&-o1MDheP+TUcF(` z9-qB>_3Hkgzk0TY_3P9bekggQPtUJ_sgv z>iR>_gQPt-^awr{0QKvxpR}Y&(+`plWHs+*v^TF%bgITQq-}F(IqCaKUC;^` zH`xTTDPT*S^!~e@9Pu{Pv5q`urCj63EFhhC&-V-NGpYf&NnL=eBwI)*g zRb7(&D0SpNBoJfwttELyTf{!dJ~eMU-35AOI?R`XWkeTs512Go<+oeV@H$f(l6p{s?G!Ac%EoP<)VG8zdK2 zo9(SyeOie}azT*&U-Cinp``m^H|&1amj1{tT?cU_?hl*fA%hUi=r0{l@gV((B)W^* zT?@TZR`vvb)|6CC@6gu0S1%+T3>b$2G8{Kk8unBk$H^aXL__U1i7(MVdctlXzea<- z)LHD1l%3E)F}AHrFN~w*bnlW(kf-lhU!_Iyl);kBp!mr9FURf#UA~_KmwD`xNH5;8 z$qPk&jXy0Ff$|W1kV5`fa-jB6cde4xw4T-j_pD`_LqKq*woHE{UVvB2OEbTE)?Ha8 zuJ|JODzLs-kM=)$&*RHBg8Z90@~jSjByg02><8~z*O1ATT@JjKhNlCi+Rxs!n`n1- zs-gQRjEVg;bbZVH2Ib$qSJ0aSsvGO5qOCOW^3h$jv|rh@~{9E-k?OGVr3sK@VD z_T`=sc=;fM5OVx1{ZUW?j>~sV_B0<`_Fo8kmV#rq?^t{uq@mz?k!zz&c!S}6T@P+q zIv=Fo0o(81y>CW%MPB%jcuC>=_pN_d?^XO9{;%Gs_+8GQ;M4U}M+l&jKelD0L0CU} zpW%0}!RPBkO-BL03X)OID8$!E&Reh=2xyRyA?VtqDJF8yyf-W1p1_3DZVe!H@@ z^bcOC`9Ej+wf1`T9f9(G^}jAtBp!l+5&Izae*Nknge~jT9>e$V-@ktS`}gnPfcfEt A`v3p{ literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame19_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame19_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..3696bf84530978652795647ac3c1d5469832f8e4 GIT binary patch literal 1766 zcmXYxPivh;6vm&Kcar-iy-nukkEG~gjA;p_!dvJ90I?P$$q}7pDSK|nh;?-i*)+T74xm7aHT!PvF zoYw^hS)rAg5(ljV$tN_NCxS?2)pL@RQe@|fnUnn3(CUmRbkbq3ZcSCiLF(1u>%<;- zW6QK?F2v(N+Rzmsb+gVTB3^g}gs`&YeKxgf6VRe*@oGA0jJ?^pslmhgM3=ukJeoc~ zJw4rfe(V0p<_UTiUrKAqQa9>o3=`NON$j%kaX^tv!Pu7l-5;j+ znyKZlDGx=;o@Rv>i1k1K;O{`zP4^4G@??l#pDIc#cE{@zeQWv6S=cVR`O>jWuo@!MT6Z44uVYqfYj~Qcsc@yFi_%+RC+Ovj${qqP z&!7D3M~XYfMooQ;)XgQ4#sNgu<1wRiS36E$-Wcp@eJtsj zkzu0fOnSv2Ocn`EMw%PDy#J_L=M|)yGqDY=>p`2FtjvuRI8Qv=+;Kzzg&`R#6zLid z*ywIH>XyNx;by9>YB6wL8Qi;3owUxpS$Y>jh=gqt#Jy}JG7c0$(aznEOZE(h}uX66^DjPvAef4ZseTyu@e5FN@m4pLi zTA||dZ@SMw!?b9!`DD$gcZarQ46b2ssUU(J&C4d2MiiTjvHl!#Jf)#C9my=dm--Q< z!)akiY+vj3iQPLujf?w0KBMY!$E=M>`JRZ8u5rfiy8e*l}~rjpz^?<3A*23J+vI*YGRKwsl@SwoGj?b zy#eZ}^B1?*R?I)y|3_t=o-Yks?=(S8?6WHPS$+iLJVEQJsBHzeDYk4W9VUI3ZhGR< zh&{~S63g5wK=h7nbTzQddN9>VV7VgT2g<&{N3y0evFZSROQ4SRl)6Km%2m6?E#W_` z{>SDv&-o`;x;-`D`!HnaVr=JB2CV2_?1zUzLkE$9T}moB@w+7cl`zg0Hp>NkwJe)} zG0w_mjL};vT*qZY+s4q32*EP}$5g}=abXM$%ePSc1^f#gBuBfW;4P)oh{<*~cV}JI j{QP_GDj!JtSf%o?8BSnPyiCAn9Lc>PMjx^r=(_#`WD3QG literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame20_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame20_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..edd267ab5cde37b0e5d29c835d58c5738b92ebf6 GIT binary patch literal 1347 zcmV-J1-$xt!ioFUluuC6KYI1+sweMP(LZ|iRTKBBXrH}bMD^>`6hrr~UcF&NKYI1+ z))Yu+pS^nZ>iQ?|UcGwDq5IdbUa+D?)Az4ly9quD_^< z)0Uy??#zd?Jy+B9REQ}-N*YG}q8kN&S?h}X)O`=t(uR=*C~0pkN*G_&&s)ZaT_frG zx=_-Flr*KHVMn088iTZ>@9e#88h+EJ^m+^XNMwU|%TCqaJwX~DwCTSqLFu67;mz+p z-C0`;vr_%Mlj@f-r|mj*e|aA(Q}+B~`Tgr@Jv0=0UrAS@pqo`jpS@-8V2|w)Nu57r z?Ig+EmA||O@FW+JT(rwFX!-I=H|Z+x9nn8&)4z34_lSKov5H2o1iX@EtKH^Xy>d`D zuE`goZ(^C!QMCSxTLd?EKYG3v zkKRK9CHI68icnvAOuaJt53jD*ykqV^-X#8xG@G{&$kHCs2P=N{qzH@NLCWOLf@k-T z_n)Wz%QFSw-&V}SCYhL!ea(9^&ty=s`_*xzmI{wSEm|JXMf3C!T@UZvAi9LiXfG<6 zl4e`OYaP#(_lWz-*gty8@ac2mAq9CI|4mz@H~mohe-7l;ySztM^H%oyH{BlcF3}zT zm?KA_k)(fa%g}fA6v-`5^5|F7B;^_dPG{$iICB;mSKw zKYO*H_pA~OJDRu1FaE%uo&V%;w0-2-liq%&m#J_6`>z3%m!ycJ-j>-9Indt)L4XSm z_q>{qNj9a!zJmZ8^)$Up2jg60C6S~Zy8Y`Z7=o)S)2w~G zsuXJm6FvbnedpX&nFZgyWg0<%QU_o-`mIl9Q&=pZDQcX)Sity}~$-ANTZ zBB#53A7+H~9nQf4-stcQU>Ro`e)WwK3Ms$(T&-Os`lLk&w^2`kZserZ!PUa_@D5-d zL0;9vtyCEO>rP+4tBLfqGGtGHc|IOXl9KP)Ztz3kAoTtHTwD=1M?us@&{OxTy)+mI zra!}zfR7J0t~KI_J@qh(nkF7}x6iOVVd&MzLxN6Q*>uo!z6yTzcX1&1gdY!01=Z5u zQU3(@ziy(MTl=V~)yU;&eh52U$Qrpgdil>a716ivSo&zDe|k~75XO#ESy&2w733HS zpt@ViE4pg?_pQ0k^#{QfAKHAr2|fwnsc7H5ZGtgv9cla4ovM#x8h-VhDA$pCN~2HS zsrtL=uRWK3q8``NTU#CVy5KHOFV<+nU>P!WwD;%0T z#L|{MbM)Tv`_!`FU@yR6@?05krNg?89?QJ`4^Ut*XI79}e)Sz{?}1B*hqQHR9jB=~-TL2sm1^#;L)ua9tt6Kf`rmz> z;s|>|`_z~Ykiy@+M}Y0?)t@KtQOk_V;{9}(p?%v)>5tx{?N8pK?hm+MzkdDu_wV1o Fe*BE4&71%L literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame20_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame20_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..30c7e34ab9a56d7f812a118427d240589a4dcd44 GIT binary patch literal 1904 zcmXAq&1;=S6vm&KcX~5vZtL76t*N*euh()dMPF-`A{557iwG_*BBHy1ttcfD`~zgt zMlDq#t+?nehzk`LSxD^i6}k~O!9`KBuz!UVH~KrT`M8-mAJ2Kt*F-KzUPbk?6qK}< zs*2a5lonBpRnb?W-BOxKm3xwNisC29XRLzP2d39Zb7})t2$OP7PP&`NBaO#tgt*}4 z)PnKUNLhEmzm8Lq@~SjM>#^FbGAfNy21#*U5eti!D_%-nXLeEknE1*L&G7|71rizi zj4_wB9xP~A9f7$Nlg1pXq*4JohhIRrquPKcov=XOcA_Pi{cK*~kr z5saEH@R`hTH?&$k_(yN2s~=r%v~jrq^uqLTf8%iQ$s6}J4j-JXWpc4?G|AF(F1R{E z2I*`;qFiW#e_e66Te&@c?arO)XxwJ%p`)WB{pxRQM;#^DdzQ=W`ztrw&)oILyCYX# z{hj_KMWYBOt5ot7s#+0h{ru-^zwPf|d-3mwt`3Aidt+VIjC!o+DfkJ`qRBgVPR-vQ z>^AT2jqps?BFJSvJV#m)>6OBFU*7AS^Qd;W{GZP&AB~I3t#n#_ADgar166l`)5c#9 z&fGXf4$xCFR80)Eu~RhpeS~#qOOQGKNaZIOpYUf-$U6`VFyurM#um%K1auVvuA#1K z7|c5u|Zd!ISWK25JkzYK07 zFCb6S3jAk^Pz<>u!L)Z~#<8hQ=^H#W~oxCm*s3z&=zyVWty#r$2NFU8K| zjGfhzY~>n|yMxTp2-R#B6>IxTiTm=xmK!}9I<{CG(>x9=pfK}vkejEbK~r!+wjr=q zo#z~F60Lh?1s!Pin{SJfOGia5h_CCqrKP^A1)174;NhW*{7Q@VkYLGWM`LYKd;&Q? zG&lwbV`~%f9Nulz6Vz_c7sZx1X4K&-tzg&&x-2RNxmD|7#?PAM472o)mA!fFN9d3Q zyk0&63U5!5$}9L;cGAN)Gp#}tbg9zPFEV-u4{6@LKj+;9)G3w3EL}jg-k3dgjYR{M z8=ITO{3FRlL-gHEKg|5SWr7T$h46@br#wAk`7f-nT23dWQ`_uXZN4Iw^ka-`;TyVX zAY~iL0N){*6(oJPEf8~Pv(LN&Lm{;YXyJRd$$Nq+;&O%|+6Y?K?D(fUF(aW`kY@QI zWmW3u(5{rao75f&n`kp)J+K_HMUh`991vu!E!!OGJeU8Ax7?vwK-3{B*s=_Ii?zx> zAhTtRjahz6CXdP28R}=brvZm!Jb?Hx0^}_1!82A2?Y{)cuXH?q3jfAVfm%_L5g}^l zu_@JvbMxj)IAqi6EPt|G=N%8|ZH8RaL+DibBGc4-P)mbqHGscmx5|0#a2? z0}r5Q5X7elA`3-p6$9o?0c}!)!eg8fM=Qu!hhN6O1bk(PGC8=c@6)!Zx!Pb{%rS%K zLsHH6w(pt#t1rhf+nv10J>S_o{jt9OzFEbwy^iy=CsC?PM4Eb5l+$0$i{ZtWuZKNW zU4%^(k>xWh$P1@V!1f1^Z^j#P?NH1YK7N3B)Cd9E_CMcV*q!`2`SyyDpNl|?(NY#2 zlzuXpI8Qy^0<+(fzxgeRxV}?8X*Qm&wcm`CFzN1PmHYXAoLxE!+m3*WdS;(iHrg?N z>mKC?mp+X)55g6gdOtYl$Dc~šýëÔxnaÔ1×®òÄ�ˆ¾d{žüŸ½z�– ¼>W^œ8C»vðô6&ãµ?~Ê—ì©~¦‘(i~¢�CK÷­aMûׯQÂk÷ïYHÃ÷¯WAï[ÈËNÔýëžä~õë{ýû÷ïß¿~ýû÷ïß¿~ýü~ýû÷ïß¿~ýû÷ïß¿~ \ No newline at end of file diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame22_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame22_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..1ccb6d1082f7d25d1cb4ce1f4a05b7d0747cb783 GIT binary patch literal 486 zcmW-ev1=4T7{z}xyM|F0*)s@-Mx0w?6H;sr3r#h|6>{ByAeIY?u{lU%W8)YuvZb79 z0v485as;%ND+CMS?35#Tjg6gufxdOvnf*TIH*ep*AyidSSUMeYSOdLYoyA{JWx!*D z7E(1+I0KErwoug5;7H$SvmMSP2~kJxsClmW%t~=fMYfZc+IXkqHMXGxU1>YMK?(I; zG1uAa&a&^zpG?*Vzf!;WI9j}Hp1@yM@2<@)J|$FA4}=)Mj0Z>SgUQCFqVx_KF~p1X z0|V@d*B6J3t;`&lVJ|)odpzXa_HRoibke*1dUU$|;l54c4so7r93B4X6Yt4|{>z5V z;IjYr7N19dR-W1^SEe0&$%FI90c?f|*JkqX`}_O*`}^x4RJfOvi^zT;8F1`O6cbw+p5hzt9INVv2dbtSM#p)nz`voxXWr{k>4{{c)gL z{r&{+z2ACY%gcYOdD^s_E#>}}eV2ja-sk^)m}!;rzgO?iDPfJjD~+b#T7Pe^d)!RF z&8iP(2D09*n6NDL+wb^y-`~eA-uL!{qtTXAFAUnxY-IC!4^;Q*%Z0L8d*|nRF6U@p zbazYl(_8j+e}ByixYRcN{k^TN=I`V7x^u3-zc*Ex`?c`*_2FUa@1swg_B!(S^}Hug PFT<%n8n94+3NZixzJ;*S literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame23_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame23_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..5287c0cfaf44d2994ceb96783755c28bf61091fa GIT binary patch literal 394 zcmWlUy-EW?6ot>sPBv>2WJ8RmuoxB%mXp?M&>*G@_yYR?iEQBmjDL{AGz)@x0nydM zLS)MkEGz}VCJ1>1AHlO1W`=t&bIy0L&`AuPERf&=hyfMAKtngd(vT^i2u49b8DJ(L zfgSixwQ#UJ(1-Y|#-<^Jrpl*;atOf+6wi1U^{Jm@`8K@?%c3Z<_x>m!=KS4RQBBj< zQH>J#a4*k~Mc(g{i#_2g2OGJ|$wQb0weGDVkE@@4{Y;jx*C&I(lNz(Y2k!d-j;;pH zSQ^y>p+yClZ;YPQkyvl+Ta@bFrwoJ0t2-Mqp=YEaC$6o=3ad4^jE>84{wv#8fQX2Y z#ts9F14zwK56E!w5ECX07SHzWy<=qm1F_%KN HgcSG(Xx2hQ literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame24_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame24_bitstream.bin new file mode 100644 index 0000000..c487343 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame24_bitstream.bin @@ -0,0 +1,3 @@ +~ýû÷ïß¿~ýû÷ïß¿~¯¤=!éû÷¯SVÂñï^¤û~1|þõ´žš +=û.ž­ì�¤™;’íÀoAyËL³&�Ÿ?gÓÕqŠF¯¼’e«Ýæ[½rP7®É¹.ä~õê/­!w¯^¿{ì2D™reÛÈÿy½Šg~ϧ«# ¾ô™zeô_]¯xÇܼIýû>ž¬ï?Oû–f Á¾­™†M31 ûšögìúz³÷î]Bf4•LÉ3-ᵎçÏÙôõ}‹º:»d¶ƒî�yûö]?eÐýOB�ôýOBŸ½k +oÞ½zŽ_¿zÊF¿zÞFZv§ï\÷#÷ïß¿~ýû÷ïß¿~ýû÷ïß¿~ýû÷ïß¿~ýû÷ï߀ \ No newline at end of file diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame24_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame24_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..b426404678912bb2ddf057753964e5d304775f1b GIT binary patch literal 420 zcmX9*Jxc>Y6r6n*!y1F!iSYw0mNQ5Y!9yDxWepYz7GkBHV6>4m{s7q+#RCZ>0sAYp zOCd4k6*giMECflRze2>y2`ve)~)>fn9K+2VTS zp=f|fW2p&a1y**UlXRvP?zG#bAdHM=CKs^aMe0`?IX-YYoNn)-5=S@caylKUY`#Il z5YF8`oxOIuXRF^|%4NX-Iw4N5$6bz0iAuh9uqYn3XGdGv#5?W;!j116y;Ld5aO|3V zEW_mV1WW`h00Fo0QkQ1oRNn#B&-capEQKilOzxUB>dN>J>?1uX$Wj5K*lXE93j?Lm lV8sX`}_O*`}^zQ)b*85I{v=CGQI2jUWR+;Cb+&riPTQ)xK~2 zZ(jOZe)qC@-1X|zx0|y6^0Hpg+gxleIJd-d^{j7x~gN z^1sed`)=8^WJ6Pf{NAcrZ+9>LrMJFJSwv{bza#R--{0T7^w(F3D*!}F#qZ7L0&1-4 i6OP|sb;=E>eiz67s(WtndyDV%BYO_y)h2&bkplo7!J&Ep literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame25_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame25_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..f18464a0725faa6baba640737bcb11f9c9050b8f GIT binary patch literal 396 zcmWlVJxc>Y5Qg8KThGgJ!iJzIDcr;)q)=EytV9-qpx7uR)`}=1L z0}_-*J)_{*g$H3Wtimrq=d1qXZ;-)iD^1uwsU~lNA_%*)=xERH#nk99WKsgstR;F} zS{hmAZP#V#!u(U|us=XU>%z36Ayh5$O2KCWoXeG42zksVnw^>iXS@iZk3}r8b$~&} g^l^cW@M$$DMjR0Ti7#u}UBoR?9AIX_z61&U0p-q0AOHXW literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame26_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame26_bitstream.bin new file mode 100644 index 0000000..9cc6ac0 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame26_bitstream.bin @@ -0,0 +1,3 @@ +~ýû÷ïß¿~ýû÷ïß¿WÒ„!è~¯¤=!éCö}ö}VZA +Œ‘\!’3ö�=Y.Oo]k2‚m­Ê +óö}=YÜ3$I{ë»Éh#ìDŒåÊ—.d>ÈýŸCÕ}Ã2'×x“fE™&do±q ‚09û>�ê¹s?J½?®#lFøŽ1þ÷A~ýŸOVþ—ˆëÞ#ÉDñÌ•.wÔÿ¦^¤ýŸOV]6õ9sq]« û¶èø‘}�úk÷¯QþýÌ º†#úõÞX�1Ì�sß“ö}=YË^Þ·X4p‡v죨lKö}?~ϧêzt‡§êzôýëXS~õëÔpšýûÖR0ýëÕÕ‰ûÖò2ýëžä~ýû÷ïß¿~ýû÷ïß¿~ýû÷ïß¿~ýû÷ïß¿~ýûð \ No newline at end of file diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame26_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame26_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..6a577fcb89cbd87c2eb8d48ef30e5a563e691e39 GIT binary patch literal 484 zcmW+yJxe4(5Pe-UN_!46>mlf=*gGnS7>tX78Z~}^n=QzR;ew(j3nnHe_R1>FMY5U* z28Kqm2pY=l(RhZPE)d5F;yN&Fh|8k0_J5vBnl@sG9_B+ez^rTf5W7m7 eOc)=%6dLe|hu4YZRH6(y8kt^+Gg`U=CDA{#W>iZ6 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame27_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame27_bitstream.bin new file mode 100644 index 0000000..0925597 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame27_bitstream.bin @@ -0,0 +1 @@ +~ýû÷ïß¿~ýû÷ïß¿kKö´¿kK÷¯RPÐ}Á.².Z·Ÿ½z’‚eÙ Þ¡ù²ñ÷ï^¤—jîÞÉ[ÿ«?NA.Ú w>‚ýëÔpo¦‚d»Õ¦LûBöKÕû¡.ïÇ~Ö—ª¬ w¾Ñ½cãþÏœ°W`Ì›ö—ÚoÞ½IyÿÞ'LŸñ¥ß™Beµ&RûUûרëÓ)Öd¼* »¯ʾßkýÞýëÔw/ðA2´Êè0‡Ì±LïÞ½GòôÐv—GûjM8D=í§ïß¿SKõ4ôƒ§êºz¦ß½zõ&¿~õ”Œ?~õ¼Œ´¬OÞ¹îGïß½zº íOß¿~ýû÷ïß¿~ýû÷ïß¿~ýû÷ïß¿~ýû÷ïÀ \ No newline at end of file diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame27_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame27_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..cb8e365d3537d5d0224d666f3cc40301f788c346 GIT binary patch literal 370 zcmW-cKS~2Z7{%YreA%o?kO?uE!eR&-EH|yyqCrd-@B({)M7Hn%BO;_Q&4SEWPC9afr2E!TtNf72%T!+k@CSE z;kRlh4W%3vJ~V{25x`9LP@jbbRX`}_O*`}^x4RJfOvi^#&IK%uy0Yg-@b-`nf??N8FrzppcGO+>1{uMhm@cgbh& z$+F%D=8Xz_x}GkJoa}yn{@$uSxAiQ0CZ(>v`z(5IQGofnlj1w|p445MzUpE5`ndho ztZ$55W)`L1KJmYDZ&!NV*H;!tHT3tY#m%f`zkWUYp5b@-_d@kRVY|;?{_jjn*dzVw z=8Tlf#zOo%*QWu6?eFh!EajYWAuiNd<;?cBYu5AatG=3cPnBf9yD8f7yZauX<6l+P zEngK7WFP8%)nM7**Vk7(H2r;j<%;j`BRvlNyYlzD75j?c9QO;ly!6H~`Cz#lTYy1G&n23T1Nx=R} z?NZ2@@(LTV2^ImlLcc=9%FTl*c6Vmp>>_KF3r{&&zz~34)Bpw8h>(DTX<>uV16$IT zR~0-6E@e(|0KL2!IUt_Ow!zYpW@P)Qj{k7%G%xloglW7oTS?~C7fiyG-e?Dnyht}BoV(##l5z8s=_aVXSqXBtrww4l!4GCkVZk~T|r5} d7K8+&;J9Ez1S3R^Jjtz~94X+}Kq^m)@DI59O)3BY literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame29_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame29_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..2ea0e60766487294cac67822933cf4cbe911bbdf GIT binary patch literal 275 zcmb>X`}_O*`}^x4RQM&i7FormzrEr&FR42>@zqZy`%Lev)4RU!Ww>{4lB+=NwZC)q z!|q*-cG~{QZqg|(#z|M-ns2pF_xPHB_p*81_3G5Oo3j7%vR=^J4bp!4_I{U1Papj+ zK6AFZ{`&UaUi^>ktG>!eSGSz`Y!dhQqi0^){7os98lF!3)4i|mUlM0*ociQySK7+f z{Gy-hrPf7%Umtj9m-Mk`(tp!9_nu2wsiUxf{e9fBsmGrS#9pu8$g+cjdZ&9oH`?$UCoa^uH^<|0Qo2tzHTKN0>=o6>?uKayH?+MVC*H^!rf#T(QmiaK> ILjs`z0OG=?N&o-= literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame29_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/frame29_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..db2f7ebd6c3870908fdbf1d635873ffc9e02ae25 GIT binary patch literal 420 zcmWkr&ntvM7=C8H@tOVl)U3^x9E`QQ?SYykC+VwAkqc#=NhGzU6uC`t_=*GK?trA! za##lkpW@=?qO=$O1Al_YVP>AU_kDi!4suo+SP9bryh577!j_~>9EcP=GUZVQAT*F# z)B<9sBm(we>-bH8XF|k@fe9j;+(<8$weDq&;|fIzxtkpkojr}B({5gdmUrjczcm`S z={$zN{}snAWi%W^9O?MtBR#3io}O27>=_4@&_LEFrV(o6jRc4H4%Xe8Tq(M6;QkI0 zK8qeV2incAv8yE^--mz<)ses%c;*NoT!B738lBJPPq-l+R$KWr-8x*%UWAMxlI;aA zcf)Q>jtoL&RUl6%(dF~XsT$e-o5tn2hw6Tx#)Y-zDI@!_n4n|~LMp)fp;`+O$CAu4 qr~UVPos|JD@w$C>7Wq{W(2>2L^s0*^8}L9Qs571n@ni{ literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/paletteLookup.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene02/paletteLookup.bin new file mode 100644 index 0000000000000000000000000000000000000000..317b960a7898a98a9b3de4fc10430cd78e626bff GIT binary patch literal 76221 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 coder.CurPos() { + coder.Code(make([]byte, header.TextOffset-coder.CurPos())) + } + data.Text = make([]byte, dataSize-int(coder.CurPos())) + coder.Code(data.Text) + return data, coder.FirstError() +} + +// Type describes the entry. +func (data SubtitleEntryData) Type() DataType { + return DataTypeSubtitle +} + +// Bytes returns the raw bytes of the entry. +func (data SubtitleEntryData) Bytes() []byte { + header := SubtitleHeader{ + Control: data.Control, + TextOffset: SubtitleDefaultTextOffset, + } + buf := bytes.NewBuffer(nil) + coder := serial.NewEncoder(buf) + coder.Code(&header) + coder.Code(make([]byte, int(header.TextOffset-coder.CurPos()))) + coder.Code(data.Text) + return buf.Bytes() +} + +// PaletteEntryData is an entry with a palette. +type PaletteEntryData struct { + Colors bitmap.Palette +} + +// PaletteEntryFrom decodes an entry from given data. +func PaletteEntryFrom(r io.Reader) (PaletteEntryData, error) { + var data PaletteEntryData + coder := serial.NewDecoder(r) + coder.Code(&data.Colors) + return data, coder.FirstError() +} + +// Type describes the entry. +func (data PaletteEntryData) Type() DataType { + return DataTypePalette +} + +// Bytes returns the raw bytes of the entry. +func (data PaletteEntryData) Bytes() []byte { + buf := bytes.NewBuffer(nil) + coder := serial.NewEncoder(buf) + coder.Code(&data.Colors) + return buf.Bytes() +} + +// PaletteResetEntryData marks a reset of the palette. +type PaletteResetEntryData struct { +} + +// PaletteResetEntryFrom decodes an entry from given data. +func PaletteResetEntryFrom() (PaletteResetEntryData, error) { + return PaletteResetEntryData{}, nil +} + +// Type describes the entry. +func (data PaletteResetEntryData) Type() DataType { + return DataTypePaletteReset +} + +// Bytes serializes the entry. +func (data PaletteResetEntryData) Bytes() []byte { + return nil +} + +// PaletteLookupEntryData contains a list of palette indices. +type PaletteLookupEntryData struct { + List []byte +} + +// PaletteLookupEntryFrom decodes an entry from given data. +func PaletteLookupEntryFrom(r io.Reader, dataSize int) (PaletteLookupEntryData, error) { + var data PaletteLookupEntryData + data.List = make([]byte, dataSize) + coder := serial.NewDecoder(r) + coder.Code(data.List) + return data, coder.FirstError() +} + +// Type describes the entry. +func (data PaletteLookupEntryData) Type() DataType { + return DataTypePaletteLookupList +} + +// Bytes serializes the entry. +func (data PaletteLookupEntryData) Bytes() []byte { + return data.List +} + +// ControlDictionaryEntryData contains control words for compression. +type ControlDictionaryEntryData struct { + Words []compression.ControlWord +} + +// ControlDictionaryEntryFrom decodes an entry from given data. +func ControlDictionaryEntryFrom(r io.Reader, dataSize int) (ControlDictionaryEntryData, error) { + var data ControlDictionaryEntryData + coder := serial.NewDecoder(r) + b := make([]byte, dataSize) + coder.Code(b) + if coder.FirstError() != nil { + return data, coder.FirstError() + } + var err error + data.Words, err = compression.UnpackControlWords(b) + return data, err +} + +// Type describes the entry. +func (data ControlDictionaryEntryData) Type() DataType { + return DataTypeControlDictionary +} + +// Bytes serializes the entry. +func (data ControlDictionaryEntryData) Bytes() []byte { + return compression.PackControlWords(data.Words) +} diff --git a/modding/hacked/ss1/content/movie/internal/format/Header.go b/modding/hacked/ss1/content/movie/internal/format/Header.go new file mode 100644 index 0000000..786210e --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/Header.go @@ -0,0 +1,73 @@ +package format + +import ( + "fmt" + "math" +) + +// Tag is the identifier key of a MOVI container. +const Tag string = "MOVI" + +// HeaderSize specifies the length of a serialized Header in bytes. +const HeaderSize = 256 + +// Fix represents a fixed-point number, with 16 bits of fraction and 15 bits of integer number. +type Fix struct { + Fraction uint16 + Number int16 +} + +// FixFromFloat returns a fixed-point number from given floating-point number. +func FixFromFloat(value float32) Fix { + number := int16(value) + return Fix{ + Fraction: uint16(math.Abs(float64(value)-float64(number)) * 0x10000), + Number: number, + } +} + +// ToFloat returns the equivalent floating-point number. +func (f Fix) ToFloat() float32 { + return float32(f.Number) + float32(f.Fraction)/0x10000 +} + +// Header is the first entry of a MOVI container. +type Header struct { + // Tag is the identifier key of a MOVI container + Tag [4]byte + // IndexEntryCount specifies how many index entries are in the index. + IndexEntryCount int32 + // IndexSize specifies how long the index is. + IndexSize int32 + // ContentSize specifies the length of the content. + ContentSize int32 + // Duration is the length of the media in seconds. + Duration Fix + + // VideoFrameRate gives a hint on number of frames per second. + VideoFrameRate Fix + // VideoWidth specifies the width of the video. + VideoWidth uint16 + // VideoHeight specifies the height of the video. + VideoHeight uint16 + // VideoBitsPerPixel specifies bit count for color information: 8, 15, 24. + VideoBitsPerPixel int16 + // VideoPalettePresent specifies whether a palette is set. + VideoPalettePresent int16 + + // AudioChannelCount specifies whether there is audio, and how many channels to use. 0 = no audio, 1 = mono, 2 = stereo. + AudioChannelCount int16 + // AudioBytesPerSample specifies how many bytes one audio sample has. 1 = 8-bit, 2 = 16-bit. + AudioBytesPerSample int16 + // AudioSampleRate specifies the sample rate of the audio. + AudioSampleRate Fix + + _ [216]byte +} + +func (header *Header) String() (result string) { + result += fmt.Sprintf("Index: %d entries (%d bytes)\n", header.IndexEntryCount, header.IndexSize) + result += fmt.Sprintf("Content: %d bytes, %f seconds\n", header.ContentSize, header.Duration.ToFloat()) + result += fmt.Sprintf("Video: %dx%d pixel @%f sample rate\n", header.VideoWidth, header.VideoHeight, header.AudioSampleRate.ToFloat()) + return +} diff --git a/modding/hacked/ss1/content/movie/internal/format/Header_test.go b/modding/hacked/ss1/content/movie/internal/format/Header_test.go new file mode 100644 index 0000000..708200e --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/Header_test.go @@ -0,0 +1,22 @@ +package format_test + +import ( + "bytes" + "encoding/binary" + "io" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" +) + +func TestHeaderSerializesToProperLength(t *testing.T) { + source := bytes.NewReader(make([]byte, 0x200)) + var header format.Header + + _ = binary.Read(source, binary.LittleEndian, &header) + curPos, _ := source.Seek(0, io.SeekCurrent) + + assert.Equal(t, int64(format.HeaderSize), curPos) +} diff --git a/modding/hacked/ss1/content/movie/internal/format/IndexTableEntry.go b/modding/hacked/ss1/content/movie/internal/format/IndexTableEntry.go new file mode 100644 index 0000000..a0e3b5a --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/IndexTableEntry.go @@ -0,0 +1,26 @@ +package format + +// IndexTableEntrySize specifies how many bytes a serialized IndexTableEntry uses. +const IndexTableEntrySize = 8 + +const indexHeaderSizeIncrement = 0x0400 + +// IndexTableEntry describes one data entry of a MOVI container. +type IndexTableEntry struct { + TimestampFraction uint16 + TimestampSecond byte + Type byte + DataOffset int32 +} + +// IndexTableSizeFor returns the number of bytes reserved for given amount of table entries. +func IndexTableSizeFor(entryCount int) int { + size := indexHeaderSizeIncrement + requiredSize := entryCount * IndexTableEntrySize + + if requiredSize > size { + size *= requiredSize/size + 2 + } + + return size +} diff --git a/modding/hacked/ss1/content/movie/internal/format/IndexTableEntry_test.go b/modding/hacked/ss1/content/movie/internal/format/IndexTableEntry_test.go new file mode 100644 index 0000000..d5d773c --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/IndexTableEntry_test.go @@ -0,0 +1,26 @@ +package format_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" +) + +func TestIndexTableSizeFor_ExistingSizes(t *testing.T) { + // These sample values are always the minimum and maximum amount of index entries + // found for a given index size. + + assert.Equal(t, 0x0400, format.IndexTableSizeFor(3)) + assert.Equal(t, 0x0400, format.IndexTableSizeFor(127)) + + assert.Equal(t, 0x0C00, format.IndexTableSizeFor(130)) + assert.Equal(t, 0x0C00, format.IndexTableSizeFor(218)) + + assert.Equal(t, 0x1C00, format.IndexTableSizeFor(738)) + assert.Equal(t, 0x1C00, format.IndexTableSizeFor(755)) + + assert.Equal(t, 0x3400, format.IndexTableSizeFor(1475)) + assert.Equal(t, 0x3400, format.IndexTableSizeFor(1523)) +} diff --git a/modding/hacked/ss1/content/movie/internal/format/SubtitleControl.go b/modding/hacked/ss1/content/movie/internal/format/SubtitleControl.go new file mode 100644 index 0000000..7d3896a --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/SubtitleControl.go @@ -0,0 +1,33 @@ +package format + +import "github.com/inkyblackness/hacked/ss1/resource" + +// SubtitleControl specifies how to interpret a subtitle entry. +type SubtitleControl uint32 + +// Subtitle constants are based on four-letter ASCII sequences. +const ( + SubtitleArea = SubtitleControl(0x41455241) + SubtitleTextStd = SubtitleControl(0x20445453) + SubtitleTextFrn = SubtitleControl(0x204E5246) + SubtitleTextGer = SubtitleControl(0x20524547) +) + +// String returns the string presentation of the control value. +func (ctrl SubtitleControl) String() string { + return string([]rune{rune((ctrl >> 0) & 0xFF), rune((ctrl >> 8) & 0xFF), rune((ctrl >> 16) & 0xFF), rune((ctrl >> 24) & 0xFF)}) +} + +// SubtitleControlForLanguage returns the corresponding subtitle control for given language. +func SubtitleControlForLanguage(lang resource.Language) SubtitleControl { + switch lang { + case resource.LangDefault: + return SubtitleTextStd + case resource.LangFrench: + return SubtitleTextFrn + case resource.LangGerman: + return SubtitleTextGer + default: + panic("unknown language") + } +} diff --git a/modding/hacked/ss1/content/movie/internal/format/SubtitleHeader.go b/modding/hacked/ss1/content/movie/internal/format/SubtitleHeader.go new file mode 100644 index 0000000..6fff670 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/SubtitleHeader.go @@ -0,0 +1,12 @@ +package format + +// SubtitleDefaultTextOffset is the typical offset, in bytes, to text content. +const SubtitleDefaultTextOffset = 16 + +// SubtitleHeader is the header structure of a subtitle data. +type SubtitleHeader struct { + // Control specifies how to interpret the string content + Control SubtitleControl + // TextOffset specifies the offset, in bytes from beginning of entry data, where the text starts. + TextOffset uint32 +} diff --git a/modding/hacked/ss1/content/movie/internal/format/Timestamp.go b/modding/hacked/ss1/content/movie/internal/format/Timestamp.go new file mode 100644 index 0000000..6f7ceff --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/Timestamp.go @@ -0,0 +1,86 @@ +package format + +import ( + "math" + "time" +) + +const fractionDivisor = 0x10000 + +// Timestamp represents a point in time using fixed resolution. +type Timestamp struct { + Second uint8 + Fraction uint16 +} + +// TimestampLimit returns the highest possible timestamp value. +func TimestampLimit() Timestamp { + return Timestamp{ + Second: math.MaxUint8, + Fraction: math.MaxUint16, + } +} + +// TimestampFromSeconds creates a timestamp instance from given floating point value. +func TimestampFromSeconds(value float32) Timestamp { + second := uint8(value) + return Timestamp{ + Second: second, + Fraction: uint16((value - float32(second)) * fractionDivisor), + } +} + +// TimestampFromDuration creates a timestamp instance from given duration value. +func TimestampFromDuration(d time.Duration) Timestamp { + limit := TimestampLimit() + if d >= limit.ToDuration() { + return limit + } + return Timestamp{ + Second: uint8(d / time.Second), + Fraction: uint16(((d - time.Second) * fractionDivisor) / time.Second), + } +} + +// ToDuration returns the equivalent duration for this timestamp. +func (ts Timestamp) ToDuration() time.Duration { + return (time.Duration(ts.Second) * time.Second) + (time.Duration(ts.Fraction)*time.Second)/fractionDivisor +} + +// IsAfter returns true if this timestamp is later than the given one. +func (ts Timestamp) IsAfter(other Timestamp) bool { + return (ts.Second > other.Second) || ((ts.Second == other.Second) && (ts.Fraction > other.Fraction)) +} + +// IsBefore returns true if this timestamp is before the given one. +func (ts Timestamp) IsBefore(other Timestamp) bool { + return (ts.Second < other.Second) || ((ts.Second == other.Second) && (ts.Fraction < other.Fraction)) +} + +// Plus returns a timestamp with the given one added to the current one. +// The result is saturated if the addition is larger than the timestamp can hold. +func (ts Timestamp) Plus(other Timestamp) Timestamp { + return TimestampFromDuration(ts.ToDuration() + other.ToDuration()) +} + +// Minus returns a timestamp with given one removed from the current one. +// The result is clipped to 0 if subtraction would result in a negative number. +func (ts Timestamp) Minus(other Timestamp) Timestamp { + otherLinear := other.toLinear() + tsLinear := ts.toLinear() + if otherLinear >= tsLinear { + return timestampFromLinear(0) + } + return timestampFromLinear(tsLinear - otherLinear) +} + +func (ts Timestamp) toLinear() uint32 { + return uint32(ts.Second)<<16 + uint32(ts.Fraction) +} + +func timestampFromLinear(value uint32) Timestamp { + return Timestamp{ + Second: uint8(value >> 16), + Fraction: uint16(value), + } +} diff --git a/modding/hacked/ss1/content/movie/internal/format/Timestamp_test.go b/modding/hacked/ss1/content/movie/internal/format/Timestamp_test.go new file mode 100644 index 0000000..cd9f3c3 --- /dev/null +++ b/modding/hacked/ss1/content/movie/internal/format/Timestamp_test.go @@ -0,0 +1,66 @@ +package format_test + +import ( + "fmt" + "math" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/movie/internal/format" +) + +func TestTimestampFromSecond(t *testing.T) { + ts := format.TimestampFromSeconds(7.25) + + assert.Equal(t, byte(7), ts.Second) + assert.Equal(t, uint16(0x4000), ts.Fraction) +} + +func TestTimeToRawB(t *testing.T) { + ts := format.TimestampFromSeconds(255.75) + + assert.Equal(t, byte(255), ts.Second) + assert.Equal(t, uint16(0xC000), ts.Fraction) +} + +func TestDurationConversion(t *testing.T) { + for fraction := 0; fraction <= math.MaxUint16; fraction++ { + ts := format.Timestamp{Second: 0, Fraction: uint16(fraction)} + dur := ts.ToDuration() + ts2 := format.TimestampFromDuration(dur) + + assert.Equal(t, ts, ts2, fmt.Sprintf("Mismatch for fraction %v", fraction)) + } +} + +func TestTimestampPlus(t *testing.T) { + tt := []struct { + a format.Timestamp + b format.Timestamp + expected format.Timestamp + }{ + { + a: format.Timestamp{Second: 0, Fraction: 0}, + b: format.Timestamp{Second: 1, Fraction: 0x8000}, + expected: format.Timestamp{Second: 1, Fraction: 0x8000}, + }, + { + a: format.Timestamp{Second: 2, Fraction: 0x8000}, + b: format.Timestamp{Second: 1, Fraction: 0x8000}, + expected: format.Timestamp{Second: 4, Fraction: 0x0000}, + }, + { + a: format.Timestamp{Second: 0xFF, Fraction: 0x0000}, + b: format.Timestamp{Second: 2, Fraction: 0x8000}, + expected: format.Timestamp{Second: 0xFF, Fraction: 0xFFFF}, + }, + } + for index, tc := range tt { + tc := tc + t.Run(fmt.Sprintf("case %d", index), func(t *testing.T) { + result := tc.a.Plus(tc.b) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/modding/hacked/ss1/content/numbers/BinaryCodedDecimal.go b/modding/hacked/ss1/content/numbers/BinaryCodedDecimal.go new file mode 100644 index 0000000..512d8b3 --- /dev/null +++ b/modding/hacked/ss1/content/numbers/BinaryCodedDecimal.go @@ -0,0 +1,26 @@ +package numbers + +// ToBinaryCodedDecimal converts given integer value to a 4-digit BCD value. +func ToBinaryCodedDecimal(value uint16) (bcd uint16) { + factors := []uint16{1000, 100, 10, 1} + value %= 10000 + + for _, factor := range factors { + bcd = (bcd << 4) | (value / factor) + value %= factor + } + + return +} + +// FromBinaryCodedDecimal converts given 4-digit BCD value into an integer. +func FromBinaryCodedDecimal(bcd uint16) (value uint16) { + factors := []uint16{1, 10, 100, 1000} + + for _, factor := range factors { + value += (bcd & 0xF) * factor + bcd >>= 4 + } + + return +} diff --git a/modding/hacked/ss1/content/numbers/BinaryCodedDecimal_test.go b/modding/hacked/ss1/content/numbers/BinaryCodedDecimal_test.go new file mode 100644 index 0000000..c5de7d7 --- /dev/null +++ b/modding/hacked/ss1/content/numbers/BinaryCodedDecimal_test.go @@ -0,0 +1,37 @@ +package numbers_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/numbers" +) + +func TestBinaryCodedDecimal(t *testing.T) { + tt := []struct { + int uint16 + bcd uint16 + }{ + {int: 0, bcd: 0x0000}, + {int: 1, bcd: 0x0001}, + {int: 20, bcd: 0x0020}, + {int: 300, bcd: 0x0300}, + {int: 4000, bcd: 0x4000}, + {int: 50000, bcd: 0x0000}, + {int: 9999, bcd: 0x9999}, + {int: 451, bcd: 0x0451}, + } + + for _, tc := range tt { + toBCD := numbers.ToBinaryCodedDecimal(tc.int) + assert.Equal(t, tc.bcd, toBCD, "Wrong result converting to BCD for %d", tc.int) + + fromBCD := numbers.FromBinaryCodedDecimal(tc.bcd) + if tc.int <= 9999 { + assert.Equal(t, tc.int, fromBCD, "Wrong result converting from BCD for %d", tc.int) + } else { + assert.Equal(t, uint16(0), fromBCD, "Wrong result converting from BCD for %d", tc.int) + } + } +} diff --git a/modding/hacked/ss1/content/object/Bitmap3D.go b/modding/hacked/ss1/content/object/Bitmap3D.go new file mode 100644 index 0000000..957ba20 --- /dev/null +++ b/modding/hacked/ss1/content/object/Bitmap3D.go @@ -0,0 +1,73 @@ +package object + +// Bitmap3D describes how a bitmap object should be represented in 3D world. +type Bitmap3D uint16 + +const ( + bitmap3DBitmapNumberLowMask uint16 = 0x03FF + bitmap3DBitmapNumberHighMask uint16 = 0x8000 + bitmap3DBitmapNumberMask = bitmap3DBitmapNumberLowMask | bitmap3DBitmapNumberHighMask + bitmap3DAnimationMask uint16 = 0x0400 + bitmap3DRepeatMask uint16 = 0x0800 + bitmap3DFrameNumberMask uint16 = 0x7000 + + // Bitmap3DBitmapNumberLimit is the maximum amount of bitmaps. + Bitmap3DBitmapNumberLimit uint16 = 0x07FF + // Bitmap3DFrameNumberLimit is the maximum amount of frames. + Bitmap3DFrameNumberLimit uint16 = 0x0007 +) + +// WithBitmapNumber returns a Bitmap3D with the given value. +func (bmp Bitmap3D) WithBitmapNumber(value uint16) Bitmap3D { + bitmapValue := value & bitmap3DBitmapNumberLowMask + if value >= Bitmap3DBitmapNumberLimit { + bitmapValue = bitmap3DBitmapNumberMask + } else if value > bitmap3DBitmapNumberLowMask { + bitmapValue |= bitmap3DBitmapNumberHighMask + } + return Bitmap3D((uint16(bmp) & ^bitmap3DBitmapNumberMask) | bitmapValue) +} + +// BitmapNumber returns the number of bitmaps. +func (bmp Bitmap3D) BitmapNumber() uint16 { + return ((uint16(bmp) & bitmap3DBitmapNumberHighMask) >> 5) | (uint16(bmp) & bitmap3DBitmapNumberLowMask) +} + +// WithFrameNumber returns a Bitmap3D with the given value. +func (bmp Bitmap3D) WithFrameNumber(value uint16) Bitmap3D { + frameValue := (value % (Bitmap3DFrameNumberLimit + 1)) << 12 + return Bitmap3D((uint16(bmp) & ^bitmap3DFrameNumberMask) | frameValue) +} + +// FrameNumber returns the frame number. +func (bmp Bitmap3D) FrameNumber() uint16 { + return (uint16(bmp) & bitmap3DFrameNumberMask) >> 12 +} + +// WithAnimation returns a Bitmap3D with the given value. +func (bmp Bitmap3D) WithAnimation(value bool) Bitmap3D { + animationValue := uint16(0) + if value { + animationValue = bitmap3DAnimationMask + } + return Bitmap3D((uint16(bmp) & ^bitmap3DAnimationMask) | animationValue) +} + +// Animation returns whether this object is animated. +func (bmp Bitmap3D) Animation() bool { + return (uint16(bmp) & bitmap3DAnimationMask) != 0 +} + +// WithRepeat returns a Bitmap3D with the given value. +func (bmp Bitmap3D) WithRepeat(value bool) Bitmap3D { + repeatValue := uint16(0) + if value { + repeatValue = bitmap3DRepeatMask + } + return Bitmap3D((uint16(bmp) & ^bitmap3DRepeatMask) | repeatValue) +} + +// Repeat returns whether this object repeats its animation. +func (bmp Bitmap3D) Repeat() bool { + return (uint16(bmp) & bitmap3DRepeatMask) != 0 +} diff --git a/modding/hacked/ss1/content/object/Bitmap3D_test.go b/modding/hacked/ss1/content/object/Bitmap3D_test.go new file mode 100644 index 0000000..ef9e13d --- /dev/null +++ b/modding/hacked/ss1/content/object/Bitmap3D_test.go @@ -0,0 +1,40 @@ +package object_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/object" +) + +func TestBitmap3DValueRange(t *testing.T) { + for _, animation := range []bool{false, true} { + for _, repeat := range []bool{false, true} { + for frameNumber := uint16(0); frameNumber <= object.Bitmap3DFrameNumberLimit; frameNumber++ { + for _, bitmapNumber := range []uint16{ + 0, + object.Bitmap3DBitmapNumberLimit / 4, + object.Bitmap3DBitmapNumberLimit / 2, + object.Bitmap3DBitmapNumberLimit / 2, + object.Bitmap3DBitmapNumberLimit/2 + 4, + object.Bitmap3DBitmapNumberLimit - 1, + object.Bitmap3DBitmapNumberLimit, + } { + key := fmt.Sprintf("%v:%v:%v:%v", animation, repeat, frameNumber, bitmapNumber) + value := object.Bitmap3D(0). + WithAnimation(animation). + WithRepeat(repeat). + WithFrameNumber(frameNumber). + WithBitmapNumber(bitmapNumber) + + assert.Equal(t, animation, value.Animation(), fmt.Sprintf("Animation wrong for %v", key)) + assert.Equal(t, repeat, value.Repeat(), fmt.Sprintf("Repeat wrong for %v", key)) + assert.Equal(t, frameNumber, value.FrameNumber(), fmt.Sprintf("Frame number wrong for %v", key)) + assert.Equal(t, bitmapNumber, value.BitmapNumber(), fmt.Sprintf("Bitmap number wrong for %v", key)) + } + } + } + } +} diff --git a/modding/hacked/ss1/content/object/CommonFlags.go b/modding/hacked/ss1/content/object/CommonFlags.go new file mode 100644 index 0000000..bfda697 --- /dev/null +++ b/modding/hacked/ss1/content/object/CommonFlags.go @@ -0,0 +1,160 @@ +package object + +import "fmt" + +// CommonFlag describe flags applicable to all object classes. +type CommonFlag int + +// String returns the textual representation of the value. +func (flag CommonFlag) String() string { + if int(flag) >= len(commonFlagNames) { + return fmt.Sprintf("Unknown 0x%04X", int(flag)) + } + return commonFlagNames[flag] +} + +func (flag CommonFlag) mask() CommonFlagField { + return CommonFlagField(1<> commonFlagLightTypeShift) +} + +// WithLightType returns a new flag field value with given light type set. +func (field CommonFlagField) WithLightType(value LightType) CommonFlagField { + return CommonFlagField((uint16(field) & ^uint16(commonFlagLightTypeMask)) | ((uint16(value) << commonFlagLightTypeShift) & commonFlagLightTypeMask)) +} + +// UseMode returns the stored enumeration value. +func (field CommonFlagField) UseMode() UseMode { + return UseMode((field & commonFlagUseModeMask) >> commonFlagUseModeShift) +} + +// WithUseMode returns a new flag field value with given use mode set. +func (field CommonFlagField) WithUseMode(value UseMode) CommonFlagField { + return CommonFlagField((uint16(field) & ^uint16(commonFlagUseModeMask)) | ((uint16(value) << commonFlagUseModeShift) & commonFlagUseModeMask)) +} + +// LightType describes how an object is affected by lighting. +type LightType byte + +// String returns the textual representation of the value. +func (mode LightType) String() string { + if int(mode) >= len(lightTypeNames) { + return fmt.Sprintf("Unknown 0x%02X", int(mode)) + } + return lightTypeNames[mode] +} + +// LightType constants. +const ( + LightTypeSimple LightType = 0x00 + LightTypeComplex LightType = 0x01 + LightTypeIgnore LightType = 0x02 + LightTypeDeferred LightType = 0x03 +) + +var lightTypeNames = []string{"Simple", "Complex", "Ignore", "Deferred"} + +// LightTypes returns all known constants. +func LightTypes() []LightType { + return []LightType{LightTypeSimple, LightTypeComplex, LightTypeIgnore, LightTypeDeferred} +} + +// UseMode describes how an object can be interacted with. +type UseMode byte + +// String returns the textual representation of the value. +func (mode UseMode) String() string { + if int(mode) >= len(useModeNames) { + return fmt.Sprintf("Unknown 0x%02X", int(mode)) + } + return useModeNames[mode] +} + +// UseMode constants. +const ( + UseModePickup UseMode = 0x00 + UseModeUse UseMode = 0x01 + UseModeNull UseMode = 0x03 +) + +var useModeNames = []string{"Pickup", "Use", "Unknown 0x02", "Null"} + +// UseModes returns all known constants. +func UseModes() []UseMode { + return []UseMode{UseModePickup, UseModeUse, UseModeNull} +} diff --git a/modding/hacked/ss1/content/object/CommonFlags_test.go b/modding/hacked/ss1/content/object/CommonFlags_test.go new file mode 100644 index 0000000..e1c84b4 --- /dev/null +++ b/modding/hacked/ss1/content/object/CommonFlags_test.go @@ -0,0 +1,98 @@ +package object_test + +import ( + "fmt" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/object" + + "github.com/stretchr/testify/assert" +) + +func TestLightTypes(t *testing.T) { + assert.Equal(t, 4, len(object.LightTypes())) +} + +func TestLightTypeString(t *testing.T) { + tt := []struct { + lightType object.LightType + expected string + }{ + {lightType: object.LightTypeSimple, expected: "Simple"}, + {lightType: object.LightTypeComplex, expected: "Complex"}, + {lightType: object.LightTypeIgnore, expected: "Ignore"}, + {lightType: object.LightTypeDeferred, expected: "Deferred"}, + + {lightType: object.LightType(255), expected: "Unknown 0xFF"}, + } + + for _, tc := range tt { + result := tc.lightType.String() + assert.Equal(t, tc.expected, result, fmt.Sprintf("Failed for 0x%02X", int(tc.lightType))) + } +} + +func TestUseModes(t *testing.T) { + assert.Equal(t, 3, len(object.UseModes())) +} + +func TestUseModeString(t *testing.T) { + tt := []struct { + useMode object.UseMode + expected string + }{ + {useMode: object.UseModePickup, expected: "Pickup"}, + {useMode: object.UseModeUse, expected: "Use"}, + {useMode: object.UseModeNull, expected: "Null"}, + + {useMode: object.UseMode(255), expected: "Unknown 0xFF"}, + } + + for _, tc := range tt { + result := tc.useMode.String() + assert.Equal(t, tc.expected, result, fmt.Sprintf("Failed for 0x%02X", int(tc.useMode))) + } +} + +func TestCommonFlagField(t *testing.T) { + for _, useMode := range object.UseModes() { + for _, lightType := range object.LightTypes() { + for _, flag := range object.CommonFlags() { + key := fmt.Sprintf("%v:%v:%v", useMode, lightType, flag) + field := object.CommonFlagField(0).WithUseMode(useMode).WithLightType(lightType).With(flag) + + assert.Equal(t, useMode, field.UseMode(), fmt.Sprintf("UseMode wrong for %v", key)) + assert.Equal(t, lightType, field.LightType(), fmt.Sprintf("LightType wrong for %v", key)) + assert.True(t, field.Has(flag), fmt.Sprintf("flag not set for %v", key)) + assert.False(t, field.Without(flag).Has(flag), fmt.Sprintf("flag still set for %v", key)) + } + } + } +} + +func TestCommonFlagString(t *testing.T) { + tt := []struct { + flag object.CommonFlag + expected string + }{ + {flag: object.CommonFlagUseful, expected: "Useful"}, + {flag: object.CommonFlagSolid, expected: "Solid"}, + {flag: object.CommonFlagUsableInventoryObject, expected: "UsableInventoryObject"}, + {flag: object.CommonFlagBlockRendering, expected: "BlockRendering"}, + {flag: object.CommonFlagSolidIfClosed, expected: "SolidIfClosed"}, + {flag: object.CommonFlagFlatSolid, expected: "FlatSolid"}, + {flag: object.CommonFlagDoubledBitmapSize, expected: "DoubleBitmapSize"}, + {flag: object.CommonFlagDestroyOnContact, expected: "DestroyOnContact"}, + {flag: object.CommonFlagClassSpecific1, expected: "ClassSpecific1"}, + {flag: object.CommonFlagClassSpecific2, expected: "ClassSpecific2"}, + {flag: object.CommonFlagClassSpecific3, expected: "ClassSpecific3"}, + {flag: object.CommonFlagUseless, expected: "Useless"}, + + {flag: object.CommonFlag(255), expected: "Unknown 0x00FF"}, + } + + for _, tc := range tt { + result := tc.flag.String() + assert.Equal(t, tc.expected, result, fmt.Sprintf("Failed for 0x%02X", int(tc.flag))) + } +} diff --git a/modding/hacked/ss1/content/object/CommonProperties.go b/modding/hacked/ss1/content/object/CommonProperties.go new file mode 100644 index 0000000..7abf2e2 --- /dev/null +++ b/modding/hacked/ss1/content/object/CommonProperties.go @@ -0,0 +1,39 @@ +package object + +const ( + // CommonPropertiesSize specifies, in bytes, the length a common properties structure has. + CommonPropertiesSize = 27 + + // HardnessLimit is the maximum hardness value. + HardnessLimit = 253 + // PhysicsXRLimit is the maximum size value. + PhysicsXRLimit = 253 + + // DefenseNoCriticals is the defense value that prohibits criticals. + DefenseNoCriticals = 0xFF + // ToughnessNoDamage is the toughness value that prohibits regular damage. + ToughnessNoDamage = 3 +) + +// CommonProperties are generic ones available for all objects. +type CommonProperties struct { + Mass int32 + Hitpoints int16 + Armor byte + RenderType RenderType + PhysicsModel PhysicsModel + Hardness byte + _ byte // pep: not used + PhysicsXR byte + _ byte // physics_y: not used + PhysicsZ byte + Vulnerabilities DamageTypeMask + SpecialVulnerabilities SpecialDamageType + _ uint16 // remainder of "resistances": not used + Defense byte + Toughness byte + Flags CommonFlagField + MfdOrMeshID uint16 + Bitmap3D Bitmap3D + DestroyEffect DestroyEffect +} diff --git a/modding/hacked/ss1/content/object/Constants.go b/modding/hacked/ss1/content/object/Constants.go new file mode 100644 index 0000000..31a7bb0 --- /dev/null +++ b/modding/hacked/ss1/content/object/Constants.go @@ -0,0 +1,8 @@ +package object + +const ( + // ClassCount describes how many object classes there are. + ClassCount = 15 + + propertiesFileVersion uint32 = 0x0000002D +) diff --git a/modding/hacked/ss1/content/object/DamageType.go b/modding/hacked/ss1/content/object/DamageType.go new file mode 100644 index 0000000..6a80ca4 --- /dev/null +++ b/modding/hacked/ss1/content/object/DamageType.go @@ -0,0 +1,103 @@ +package object + +import "fmt" + +// DamageType classifies damage. +type DamageType byte + +// String returns the textual representation of the value. +func (damageType DamageType) String() string { + if int(damageType) >= len(damageTypeNames) { + return fmt.Sprintf("Unknown 0x%02X", int(damageType)) + } + return damageTypeNames[damageType] +} + +func (damageType DamageType) mask() DamageTypeMask { + return 1 << damageType +} + +// DamageType constants. +const ( + DamageTypeExplosion DamageType = 0 + DamageTypeEnergy DamageType = 1 + DamageTypeMagnetic DamageType = 2 + DamageTypeRadiation DamageType = 3 + DamageTypeGas DamageType = 4 + DamageTypeTranquilizer DamageType = 5 + DamageTypeNeedle DamageType = 6 + DamageTypeBio DamageType = 7 +) + +var damageTypeNames = []string{ + "Explosion", "Energy", "Magnetic", "Radiation", + "Gas", "Tranquilizer", "Needle", "Bio", +} + +// DamageTypes returns all known constants. +func DamageTypes() []DamageType { + return []DamageType{ + DamageTypeExplosion, DamageTypeEnergy, DamageTypeMagnetic, DamageTypeRadiation, + DamageTypeGas, DamageTypeTranquilizer, DamageTypeNeedle, DamageTypeBio, + } +} + +// DamageTypeMask combines a set of damage types. +type DamageTypeMask byte + +// Has returns whether the mask contains the specified value. +func (mask DamageTypeMask) Has(dmg DamageType) bool { + return (mask & dmg.mask()) != 0 +} + +// With returns a new mask that specifies the combination of this and the given damage type. +func (mask DamageTypeMask) With(dmg DamageType) DamageTypeMask { + return mask | dmg.mask() +} + +// Without returns a new mask that specifies the remainder of this, not including the given type. +func (mask DamageTypeMask) Without(dmg DamageType) DamageTypeMask { + return mask & ^dmg.mask() +} + +// SpecialDamageType is a combination of "primary" (double) and "super" (quadruple) damage potential. +// The identifier are "freeform" enumerations without public constants. +type SpecialDamageType byte + +const ( + specialDamageTypePrimaryShift = 0 + specialDamageTypePrimaryMask = 0x0F + specialDamageTypeSuperShift = 4 + specialDamageTypeSuperMask = 0xF0 + + // SpecialDamageTypeLimit identifies the maximum value of special damage type. + SpecialDamageTypeLimit = 0x0F +) + +// PrimaryValue returns the double damage type. +func (dmg SpecialDamageType) PrimaryValue() int { + return int((dmg & specialDamageTypePrimaryMask) >> specialDamageTypePrimaryShift) +} + +// WithPrimaryValue returns a new type instance with the given primary value set. +func (dmg SpecialDamageType) WithPrimaryValue(value int) SpecialDamageType { + result := dmg + if (value >= 0) && (value <= SpecialDamageTypeLimit) { + result = SpecialDamageType((byte(result) & ^byte(specialDamageTypePrimaryMask)) | byte(value<> specialDamageTypeSuperShift) +} + +// WithSuperValue returns a new type instance with the given super value set. +func (dmg SpecialDamageType) WithSuperValue(value int) SpecialDamageType { + result := dmg + if (value >= 0) && (value <= SpecialDamageTypeLimit) { + result = SpecialDamageType((byte(result) & ^byte(specialDamageTypeSuperMask)) | byte(value<= len(physicsModelNames) { + return fmt.Sprintf("Unknown 0x%02X", int(model)) + } + return physicsModelNames[model] +} + +// PhysicsModel constants. +const ( + PhysicsModelInsubstantial PhysicsModel = 0 + PhysicsModelRegular PhysicsModel = 1 + PhysicsModelStrange PhysicsModel = 2 +) + +var physicsModelNames = []string{ + "Insubstantial", + "Regular", + "Strange", +} + +// PhysicsModels returns all known constants. +func PhysicsModels() []PhysicsModel { + return []PhysicsModel{ + PhysicsModelInsubstantial, PhysicsModelRegular, PhysicsModelStrange, + } +} diff --git a/modding/hacked/ss1/content/object/PhysicsModel_test.go b/modding/hacked/ss1/content/object/PhysicsModel_test.go new file mode 100644 index 0000000..dbe8723 --- /dev/null +++ b/modding/hacked/ss1/content/object/PhysicsModel_test.go @@ -0,0 +1,32 @@ +package object_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/object" +) + +func TestPhysicsModels(t *testing.T) { + assert.Equal(t, 3, len(object.PhysicsModels())) +} + +func TestPhysicsModelString(t *testing.T) { + tt := []struct { + model object.PhysicsModel + expected string + }{ + {model: object.PhysicsModelInsubstantial, expected: "Insubstantial"}, + {model: object.PhysicsModelRegular, expected: "Regular"}, + {model: object.PhysicsModelStrange, expected: "Strange"}, + + {model: object.PhysicsModel(255), expected: "Unknown 0xFF"}, + } + + for _, tc := range tt { + result := tc.model.String() + assert.Equal(t, tc.expected, result, fmt.Sprintf("Failed for 0x%02X", int(tc.model))) + } +} diff --git a/modding/hacked/ss1/content/object/Pivot.go b/modding/hacked/ss1/content/object/Pivot.go new file mode 100644 index 0000000..c286ac3 --- /dev/null +++ b/modding/hacked/ss1/content/object/Pivot.go @@ -0,0 +1,17 @@ +package object + +// Pivot returns the assumed height offset for placing objects. +func Pivot(prop CommonProperties) float32 { + const DefaultHeight = 1.0 / float32(0xbd00) + const PhysicsScale = 96.0 + + switch { + case (prop.RenderType == RenderTypeTextPoly) || (prop.RenderType == RenderTypeSpecial): + return 0.0 + case prop.PhysicsZ != 0: + return float32(prop.PhysicsZ) / PhysicsScale + case prop.PhysicsXR != 0: + return float32(prop.PhysicsXR) / PhysicsScale + } + return DefaultHeight +} diff --git a/modding/hacked/ss1/content/object/Properties.go b/modding/hacked/ss1/content/object/Properties.go new file mode 100644 index 0000000..ec8a990 --- /dev/null +++ b/modding/hacked/ss1/content/object/Properties.go @@ -0,0 +1,160 @@ +package object + +import ( + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/serial" +) + +const ( + errInvalidClass ss1.StringError = "invalid class" + errInvalidSubclass ss1.StringError = "invalid subclass" + errInvalidType ss1.StringError = "invalid type" +) + +// PropertiesTable is a collection of class-specific properties. +type PropertiesTable []ClassProperties + +// ClassProperties is a collection of subclass-specific properties. +type ClassProperties []SubclassProperties + +// SubclassProperties is a collection of type-specific properties. +type SubclassProperties []Properties + +// Properties contains the object-specific properties. +type Properties struct { + Common CommonProperties + Generic []byte + Specific []byte +} + +// Clone returns a fresh instance with a copy of the data. +func (prop Properties) Clone() Properties { + cloneBytes := func(in []byte) []byte { + out := make([]byte, len(in)) + copy(out, in) + return out + } + return Properties{ + Common: prop.Common, + Generic: cloneBytes(prop.Generic), + Specific: cloneBytes(prop.Specific), + } +} + +// NewPropertiesTable returns a new instance based on given descriptors. +func NewPropertiesTable(desc Descriptors) PropertiesTable { + classCount := len(desc) + table := make([]ClassProperties, classCount) + for class := 0; class < classCount; class++ { + classDesc := desc[class] + subclassCount := len(classDesc.Subclasses) + subclassProperties := make([]SubclassProperties, subclassCount) + table[class] = subclassProperties + for subclass := 0; subclass < subclassCount; subclass++ { + subclassDesc := classDesc.Subclasses[subclass] + typeProperties := make([]Properties, subclassDesc.TypeCount) + subclassProperties[subclass] = typeProperties + for objType := 0; objType < subclassDesc.TypeCount; objType++ { + prop := &typeProperties[objType] + prop.Generic = make([]byte, classDesc.GenericDataSize) + prop.Specific = make([]byte, subclassDesc.SpecificDataSize) + } + } + } + return table +} + +// ForObject returns the object-specific properties by given triple. +func (table PropertiesTable) ForObject(triple Triple) (*Properties, error) { + if int(triple.Class) >= len(table) { + return nil, errInvalidClass + } + classEntry := table[triple.Class] + if int(triple.Subclass) >= len(classEntry) { + return nil, errInvalidSubclass + } + subclassEntry := classEntry[triple.Subclass] + if int(triple.Type) >= len(subclassEntry) { + return nil, errInvalidType + } + return &subclassEntry[triple.Type], nil +} + +// Code serializes the table with given coder. +func (table PropertiesTable) Code(coder serial.Coder) { + version := propertiesFileVersion + coder.Code(&version) + for _, subclasses := range table { + for _, types := range subclasses { + for _, prop := range types { + coder.Code(prop.Generic) + } + } + for _, types := range subclasses { + for _, prop := range types { + coder.Code(prop.Specific) + } + } + } + for _, subclasses := range table { + for _, types := range subclasses { + for objType := 0; objType < len(types); objType++ { + coder.Code(&types[objType].Common) + } + } + } +} + +// TriplesInClass returns all triples that are available in the given class. +func (table PropertiesTable) TriplesInClass(class Class) []Triple { + var triples []Triple + if int(class) < len(table) { + subclasses := table[class] + for subclass, types := range subclasses { + for objType := range types { + triples = append(triples, TripleFrom(int(class), subclass, objType)) + } + } + } + return triples +} + +// TripleIndex returns the linear index of the given index. +func (table PropertiesTable) TripleIndex(triple Triple) int { + if int(triple.Class) >= len(table) { + return -1 + } + counter := 0 + for class := Class(0); class < triple.Class; class++ { + subclasses := table[class] + for _, types := range subclasses { + counter += len(types) + } + } + subclasses := table[triple.Class] + if int(triple.Subclass) >= len(subclasses) { + return -1 + } + for subclass := Subclass(0); subclass < triple.Subclass; subclass++ { + counter += len(subclasses[subclass]) + } + types := subclasses[triple.Subclass] + if int(triple.Type) >= len(types) { + return -1 + } + return counter + int(triple.Type) +} + +// Iterate walks through all types sequentially and gives the properties to the given consumer. +// The iteration stops when the consumer returns false or the table has been exhausted. +func (table PropertiesTable) Iterate(consumer func(Triple, *Properties) bool) { + for class, subclasses := range table { + for subclass, types := range subclasses { + for objType := 0; objType < len(types); objType++ { + if !consumer(TripleFrom(class, subclass, objType), &types[objType]) { + return + } + } + } + } +} diff --git a/modding/hacked/ss1/content/object/Properties_test.go b/modding/hacked/ss1/content/object/Properties_test.go new file mode 100644 index 0000000..9bd811d --- /dev/null +++ b/modding/hacked/ss1/content/object/Properties_test.go @@ -0,0 +1,58 @@ +package object_test + +import ( + "bytes" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" +) + +func TestPropertiesForObjectReturnErrorForWrongIDs(t *testing.T) { + tt := []struct { + name string + triple object.Triple + }{ + {"wrong class", object.TripleFrom(20, 0, 2)}, + {"wrong subclass", object.TripleFrom(1, 40, 0)}, + {"wrong type", object.TripleFrom(1, 0, 20)}, + } + table := object.StandardPropertiesTable() + + for _, tc := range tt { + _, err := table.ForObject(tc.triple) + assert.Error(t, err, "error expected for "+tc.name) + } +} + +func TestPropertiesForObjectData(t *testing.T) { + tt := []struct { + triple object.Triple + genSize int + specSize int + }{ + {object.TripleFrom(0, 0, 0), 2, 1}, + {object.TripleFrom(14, 4, 1), 75, 1}, + {object.TripleFrom(10, 3, 4), 1, 1}, + } + table := object.StandardPropertiesTable() + + for _, tc := range tt { + prop, err := table.ForObject(tc.triple) + assert.Nil(t, err, "no error expected for "+tc.triple.String()) + assert.Equal(t, tc.genSize, len(prop.Generic), "wrong generic size for "+tc.triple.String()) + assert.Equal(t, tc.specSize, len(prop.Specific), "wrong specific size for "+tc.triple.String()) + } +} + +func TestPropertiesEncoding(t *testing.T) { + buf := bytes.NewBuffer(nil) + encoder := serial.NewEncoder(buf) + table := object.StandardPropertiesTable() + + table.Code(encoder) + result := buf.Bytes() + assert.Equal(t, 17951, len(result)) // as taken from original CD +} diff --git a/modding/hacked/ss1/content/object/RenderType.go b/modding/hacked/ss1/content/object/RenderType.go new file mode 100644 index 0000000..2d62692 --- /dev/null +++ b/modding/hacked/ss1/content/object/RenderType.go @@ -0,0 +1,48 @@ +package object + +import "fmt" + +// RenderType defines how an object is to be rendered. +type RenderType byte + +// String returns the textual representation of the value. +func (renderType RenderType) String() string { + if int(renderType) >= len(renderTypeNames) { + return fmt.Sprintf("Unknown 0x%02X", int(renderType)) + } + return renderTypeNames[renderType] +} + +// RenderType constants. +const ( + RenderTypeUnknown RenderType = 0 + RenderTypeTextPoly RenderType = 1 + RenderTypeBitmap RenderType = 2 + RenderTypeTPoly RenderType = 3 + RenderTypeCritter RenderType = 4 + RenderTypeAnimPoly RenderType = 5 + RenderTypeVox RenderType = 6 + RenderTypeNoObject RenderType = 7 + RenderTypeTexBitmap RenderType = 8 + RenderTypeFlatPoly RenderType = 9 + RenderTypeMultiView RenderType = 10 + RenderTypeSpecial RenderType = 11 + RenderTypeTLPoly RenderType = 12 +) + +var renderTypeNames = []string{ + "Unknown 0x00", + "TextPoly", "Bitmap", "TPoly", "Critter", + "AnimPoly", "Vox", "NoObject", "TexBitmap", + "FlatPoly", "MultiView", "Special", "TLPoly", +} + +// RenderTypes returns all known constants. +func RenderTypes() []RenderType { + return []RenderType{ + RenderTypeUnknown, + RenderTypeTextPoly, RenderTypeBitmap, RenderTypeTPoly, RenderTypeCritter, + RenderTypeAnimPoly, RenderTypeVox, RenderTypeNoObject, RenderTypeTexBitmap, + RenderTypeFlatPoly, RenderTypeMultiView, RenderTypeSpecial, RenderTypeTLPoly, + } +} diff --git a/modding/hacked/ss1/content/object/RenderType_test.go b/modding/hacked/ss1/content/object/RenderType_test.go new file mode 100644 index 0000000..70e6c6f --- /dev/null +++ b/modding/hacked/ss1/content/object/RenderType_test.go @@ -0,0 +1,42 @@ +package object_test + +import ( + "fmt" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/object" + + "github.com/stretchr/testify/assert" +) + +func TestRenderTypes(t *testing.T) { + assert.Equal(t, 13, len(object.RenderTypes())) +} + +func TestRenderTypeString(t *testing.T) { + tt := []struct { + renderType object.RenderType + expected string + }{ + {renderType: object.RenderTypeUnknown, expected: "Unknown 0x00"}, + {renderType: object.RenderTypeTextPoly, expected: "TextPoly"}, + {renderType: object.RenderTypeBitmap, expected: "Bitmap"}, + {renderType: object.RenderTypeTPoly, expected: "TPoly"}, + {renderType: object.RenderTypeCritter, expected: "Critter"}, + {renderType: object.RenderTypeAnimPoly, expected: "AnimPoly"}, + {renderType: object.RenderTypeVox, expected: "Vox"}, + {renderType: object.RenderTypeNoObject, expected: "NoObject"}, + {renderType: object.RenderTypeTexBitmap, expected: "TexBitmap"}, + {renderType: object.RenderTypeFlatPoly, expected: "FlatPoly"}, + {renderType: object.RenderTypeMultiView, expected: "MultiView"}, + {renderType: object.RenderTypeSpecial, expected: "Special"}, + {renderType: object.RenderTypeTLPoly, expected: "TLPoly"}, + + {renderType: object.RenderType(255), expected: "Unknown 0xFF"}, + } + + for _, tc := range tt { + result := tc.renderType.String() + assert.Equal(t, tc.expected, result, fmt.Sprintf("Failed for 0x%02X", int(tc.renderType))) + } +} diff --git a/modding/hacked/ss1/content/object/Standards.go b/modding/hacked/ss1/content/object/Standards.go new file mode 100644 index 0000000..7920176 --- /dev/null +++ b/modding/hacked/ss1/content/object/Standards.go @@ -0,0 +1,177 @@ +package object + +// StandardPropertiesTable returns a properties table based on the standard +// configuration of the existing objprop.dat file. +func StandardPropertiesTable() PropertiesTable { + return NewPropertiesTable(StandardDescriptors()) +} + +// StandardDescriptors returns an array of class descriptors that represent the standard +// configuration of the existing objprop.dat file. +func StandardDescriptors() Descriptors { + var result Descriptors + + { // Guns + subclasses := []SubclassDescriptor{ + {TypeCount: 5, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 16}, + {TypeCount: 2, SpecificDataSize: 13}, + {TypeCount: 3, SpecificDataSize: 13}, + {TypeCount: 2, SpecificDataSize: 18}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 2, Subclasses: subclasses}) + } + { // Ammo + subclasses := []SubclassDescriptor{ + {TypeCount: 2, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 1}, + {TypeCount: 3, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 14, Subclasses: subclasses}) + } + { // Physics + subclasses := []SubclassDescriptor{ + {TypeCount: 6, SpecificDataSize: 20}, + {TypeCount: 16, SpecificDataSize: 6}, + {TypeCount: 2, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 1, Subclasses: subclasses}) + } + { // Grenades + subclasses := []SubclassDescriptor{ + {TypeCount: 5, SpecificDataSize: 1}, + {TypeCount: 3, SpecificDataSize: 3}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 15, Subclasses: subclasses}) + } + { // Drugs + subclasses := []SubclassDescriptor{ + {TypeCount: 7, SpecificDataSize: 7}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 17, Subclasses: subclasses}) + } + { // Hardware + subclasses := []SubclassDescriptor{ + {TypeCount: 5, SpecificDataSize: 1}, + {TypeCount: 10, SpecificDataSize: 2}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 2, Subclasses: subclasses}) + } + { // Software + subclasses := []SubclassDescriptor{ + {TypeCount: 7, SpecificDataSize: 1}, + {TypeCount: 3, SpecificDataSize: 1}, + {TypeCount: 4, SpecificDataSize: 1}, + {TypeCount: 5, SpecificDataSize: 1}, + {TypeCount: 3, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 2, Subclasses: subclasses}) + } + { // BigStuff + subclasses := []SubclassDescriptor{ + {TypeCount: 9, SpecificDataSize: 1}, + {TypeCount: 10, SpecificDataSize: 1}, + {TypeCount: 11, SpecificDataSize: 1}, + {TypeCount: 4, SpecificDataSize: 1}, + {TypeCount: 9, SpecificDataSize: 1}, + {TypeCount: 8, SpecificDataSize: 1}, + {TypeCount: 16, SpecificDataSize: 1}, + {TypeCount: 10, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 4, Subclasses: subclasses}) + } + { // SmallStuff + subclasses := []SubclassDescriptor{ + {TypeCount: 8, SpecificDataSize: 1}, + {TypeCount: 10, SpecificDataSize: 1}, + {TypeCount: 15, SpecificDataSize: 1}, + {TypeCount: 6, SpecificDataSize: 1}, + {TypeCount: 12, SpecificDataSize: 1}, + {TypeCount: 12, SpecificDataSize: 6}, + {TypeCount: 9, SpecificDataSize: 1}, + {TypeCount: 8, SpecificDataSize: 2}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 2, Subclasses: subclasses}) + } + { // Fixtures + subclasses := []SubclassDescriptor{ + {TypeCount: 9, SpecificDataSize: 1}, + {TypeCount: 7, SpecificDataSize: 1}, + {TypeCount: 3, SpecificDataSize: 1}, + {TypeCount: 11, SpecificDataSize: 1}, + {TypeCount: 2, SpecificDataSize: 0}, // skipped vending machines + {TypeCount: 3, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 1, Subclasses: subclasses}) + } + { // Doors + subclasses := []SubclassDescriptor{ + {TypeCount: 10, SpecificDataSize: 1}, + {TypeCount: 9, SpecificDataSize: 1}, + {TypeCount: 7, SpecificDataSize: 1}, + {TypeCount: 5, SpecificDataSize: 1}, + {TypeCount: 10, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 1, Subclasses: subclasses}) + } + { // Animating + subclasses := []SubclassDescriptor{ + {TypeCount: 9, SpecificDataSize: 1}, + {TypeCount: 11, SpecificDataSize: 1}, + {TypeCount: 14, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 2, Subclasses: subclasses}) + } + { // Traps + subclasses := []SubclassDescriptor{ + {TypeCount: 13, SpecificDataSize: 1}, + {TypeCount: 1, SpecificDataSize: 1}, + {TypeCount: 5, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 1, Subclasses: subclasses}) + } + { // Containers + subclasses := []SubclassDescriptor{ + {TypeCount: 3, SpecificDataSize: 1}, + {TypeCount: 3, SpecificDataSize: 1}, + {TypeCount: 4, SpecificDataSize: 1}, + {TypeCount: 8, SpecificDataSize: 1}, + {TypeCount: 13, SpecificDataSize: 1}, + {TypeCount: 7, SpecificDataSize: 1}, + {TypeCount: 8, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 3, Subclasses: subclasses}) + } + { // Critters + subclasses := []SubclassDescriptor{ + {TypeCount: 9, SpecificDataSize: 1}, + {TypeCount: 12, SpecificDataSize: 2}, + {TypeCount: 7, SpecificDataSize: 2}, + {TypeCount: 7, SpecificDataSize: 6}, + {TypeCount: 2, SpecificDataSize: 1}, + } + + result = append(result, ClassDescriptor{GenericDataSize: 75, Subclasses: subclasses}) + } + + return result +} diff --git a/modding/hacked/ss1/content/object/Standards_test.go b/modding/hacked/ss1/content/object/Standards_test.go new file mode 100644 index 0000000..72aa9a3 --- /dev/null +++ b/modding/hacked/ss1/content/object/Standards_test.go @@ -0,0 +1,29 @@ +package object_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/object" +) + +func TestStandardDescriptorsReturnsProperLength(t *testing.T) { + descriptor := object.StandardDescriptors() + totalLength := 4 // version prefix + + for _, classDesc := range descriptor { + totalLength += classDesc.TotalDataSize() + } + assert.Equal(t, 17951, totalLength) // as taken from original CD +} + +func TestStandardDescriptorsReturnsProperAmount(t *testing.T) { + descriptor := object.StandardDescriptors() + total := 0 + + for _, classDesc := range descriptor { + total += classDesc.TotalTypeCount() + } + assert.Equal(t, 476, total) +} diff --git a/modding/hacked/ss1/content/object/Triple.go b/modding/hacked/ss1/content/object/Triple.go new file mode 100644 index 0000000..fdc9590 --- /dev/null +++ b/modding/hacked/ss1/content/object/Triple.go @@ -0,0 +1,104 @@ +package object + +import "fmt" + +// Class describes a general category of objects. +type Class byte + +// String returns the textual representation. +func (c Class) String() string { + if int(c) >= len(classNames) { + return fmt.Sprintf("Unknown%02X", int(c)) + } + return classNames[c] +} + +// Object classes constants. +const ( + ClassGun Class = 0 + ClassAmmo Class = 1 + ClassPhysics Class = 2 + ClassGrenade Class = 3 + ClassDrug Class = 4 + ClassHardware Class = 5 + ClassSoftware Class = 6 + ClassBigStuff Class = 7 + ClassSmallStuff Class = 8 + ClassFixture Class = 9 + ClassDoor Class = 10 + ClassAnimating Class = 11 + ClassTrap Class = 12 + ClassContainer Class = 13 + ClassCritter Class = 14 +) + +var classNames = []string{ + "Gun", + "Ammo", + "Physics", + "Grenade", + "Drug", + "Hardware", + "Software", + "BigStuff", + "SmallStuff", + "Fixture", + "Door", + "Animating", + "Trap", + "Container", + "Critter", +} + +// Classes returns a list of all classes. +func Classes() []Class { + var classes [ClassCount]Class + for class := Class(0); class < ClassCount; class++ { + classes[class] = class + } + return classes[:] +} + +// Subclass divides an object class. +type Subclass byte + +// Type describes one specific object. +type Type byte + +// Triple identifies one specific object by its full coordinate. +type Triple struct { + Class Class + Subclass Subclass + Type Type +} + +// TripleFrom returns a Triple instance with given values as coordinates. +func TripleFrom(class, subclass, objType int) Triple { + return Triple{ + Class: Class(class), + Subclass: Subclass(subclass), + Type: Type(objType), + } +} + +// TripleFromInt returns a Triple instance based on a single integer value. +func TripleFromInt(value int) Triple { + return Triple{ + Class: Class((value >> 16) & 0xFF), + Subclass: Subclass((value >> 8) & 0xFF), + Type: Type((value >> 0) & 0xFF), + } +} + +// String returns the textual representation of the triple as "cl/s/ty" string. +func (triple Triple) String() string { + return fmt.Sprintf("%2d/%d/%2d", triple.Class, triple.Subclass, triple.Type) +} + +// Int returns the triple as a single integer value. +func (triple Triple) Int() int { + value := int(triple.Class) << 16 + value |= int(triple.Subclass) << 8 + value |= int(triple.Type) << 0 + return value +} diff --git a/modding/hacked/ss1/content/object/Triple_test.go b/modding/hacked/ss1/content/object/Triple_test.go new file mode 100644 index 0000000..7dbda32 --- /dev/null +++ b/modding/hacked/ss1/content/object/Triple_test.go @@ -0,0 +1,50 @@ +package object_test + +import ( + "fmt" + "testing" + + "github.com/inkyblackness/hacked/ss1/content/object" + + "github.com/stretchr/testify/assert" +) + +func TestClasses(t *testing.T) { + classes := object.Classes() + assert.Equal(t, 15, len(classes)) +} + +func TestClassString(t *testing.T) { + assert.Equal(t, "Gun", object.Class(0).String()) + assert.Equal(t, "UnknownFF", object.Class(0xFF).String()) +} + +func TestTripleString(t *testing.T) { + tt := []struct { + triple object.Triple + expected string + }{ + {object.TripleFrom(1, 2, 3), " 1/2/ 3"}, + {object.TripleFrom(20, 0, 10), "20/0/10"}, + } + for _, tc := range tt { + result := tc.triple.String() + assert.Equal(t, tc.expected, result) + } +} + +func TestTripleIntegerRepresentation(t *testing.T) { + tt := []struct { + triple object.Triple + expected int + }{ + {object.TripleFrom(1, 2, 3), 0x00010203}, + {object.TripleFrom(20, 0, 10), 0x0014000A}, + } + for _, tc := range tt { + result := tc.triple.Int() + assert.Equal(t, tc.expected, result, fmt.Sprintf("integer value not matching for %v", tc.triple)) + reversed := object.TripleFromInt(tc.expected) + assert.Equal(t, tc.triple, reversed, fmt.Sprintf("reverse value not matching for %v", tc.triple)) + } +} diff --git a/modding/hacked/ss1/content/object/doc.go b/modding/hacked/ss1/content/object/doc.go new file mode 100644 index 0000000..e2db13d --- /dev/null +++ b/modding/hacked/ss1/content/object/doc.go @@ -0,0 +1,2 @@ +// Package object contains the game-global properties of objects. +package object diff --git a/modding/hacked/ss1/content/object/objprop/Ammo.go b/modding/hacked/ss1/content/object/objprop/Ammo.go new file mode 100644 index 0000000..a0559a0 --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/Ammo.go @@ -0,0 +1,20 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var ammoClipGenerics = interpreters.New(). + Refining("BasicWeapon", 0, 8, basicWeapon, interpreters.Always). + With("CartrigeSize", 8, 1). + With("BulletMass", 9, 1). + With("BulletSpeed", 10, 2).As(interpreters.RangedValue(-10000, +10000)). + With("Range", 12, 1). + With("RecoilForce", 13, 1) + +func initAmmo() { + objClass := object.Class(1) + + genericDescriptions[objClass] = ammoClipGenerics +} diff --git a/modding/hacked/ss1/content/object/objprop/Animating.go b/modding/hacked/ss1/content/object/objprop/Animating.go new file mode 100644 index 0000000..d29752d --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/Animating.go @@ -0,0 +1,26 @@ +package objprop + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var animationGenerics = interpreters.New(). + With("FrameTime", 0, 1).As(interpreters.FormattedRangedValue(0, 255, + func(value int) string { + return fmt.Sprintf("%3.0f millisec", (float64(value)*900)/255.0) + })). + With("Flags", 1, 1).As(interpreters.Bitfield(map[uint32]string{0x01: "Emit Light"})) + +var explosionAnimation = interpreters.New(). + With("FrameExplode", 0, 1) + +func initAnimating() { + objClass := object.Class(11) + + genericDescriptions[objClass] = animationGenerics + + setSpecific(objClass, 2, explosionAnimation) +} diff --git a/modding/hacked/ss1/content/object/objprop/Critters.go b/modding/hacked/ss1/content/object/objprop/Critters.go new file mode 100644 index 0000000..4d59c20 --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/Critters.go @@ -0,0 +1,72 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var critterAttackInfo = interpreters.New(). + With("DamageType", 0x0000, 1).As(damageType). + With("SpecialDamageType", 0x0001, 1).As(specialDamageType). + With("DamageModifier", 0x0004, 2).As(interpreters.RangedValue(0, 500)). + With("OffenceValue", 0x0006, 1). + With("Penetration", 0x0007, 1). + With("AttackMass", 0x0008, 1). + With("AttackVelocity", 0x0009, 2). + With("Accuracy", 0x000B, 1). + With("AttackRange", 0x000C, 1). + With("ReloadTime", 0x000D, 2).As(interpreters.RangedValue(0, 1000)). + With("Projectile", 0x0011, 4).As(interpreters.SpecialValue("ObjectTriple")) + +var critterGenerics = interpreters.New(). + Refining("PrimaryAttack", 0x0001, 21, critterAttackInfo, interpreters.Always). + Refining("SecondaryAttack", 0x0016, 21, critterAttackInfo, interpreters.Always). + With("Perception", 0x002B, 1). + With("Defence", 0x002C, 1). + With("ProjectileSourceHeightOffset", 0x002D, 1).As(interpreters.RangedValue(-128, 127)). + With("Flags", 0x002E, 1).As(interpreters.Bitfield(map[uint32]string{ + 0x01: "Flying"})). + With("AnimationSpeed", 0x003B, 1). + With("AttackSoundIndex", 0x003C, 1). + With("NearSoundIndex", 0x003D, 1). + With("HurtSoundIndex", 0x003E, 1). + With("DeathSoundIndex", 0x003F, 1). + With("NoticeSoundIndex", 0x0040, 1). + With("Corpse", 0x0041, 4).As(interpreters.SpecialValue("ObjectTriple")). + With("ViewCount", 0x0045, 1). + With("SecondaryAttackProbability", 0x0046, 1). + With("DisruptProbability", 0x0047, 1). + With("TreasureType", 0x0048, 1).As(interpreters.EnumValue(map[uint32]string{ + 0x00: "No treasure", + 0x01: "Humanoid", + 0x02: "Drone", + 0x03: "Assassin", + 0x04: "Warrior cyborg", + 0x05: "Flier bot", + 0x06: "Security 1 bot", + 0x07: "Exec bot", + 0x08: "Cyborg enforcer", + 0x09: "Security 2 bot", + 0x0A: "Elite cyborg", + 0x0B: "Standard corpse", + 0x0C: "Loot-oriented corpse", + 0x0D: "Electro-stuff treasure", + 0x0E: "Serv-bot"})). + With("HitEffect", 0x0049, 1).As(interpreters.EnumValue(map[uint32]string{0: "meat", 1: "plant", 2: "robot", 3: "cyborg", 4: "generic"})). + With("AttackKeyFrame", 0x004A, 1).As(interpreters.RangedValue(0, 10)) + +var cyberCritters = interpreters.New(). + With("Color0", 0, 1). + With("Color1", 1, 1). + With("Color2", 2, 1). + With("AltColor0", 3, 1). + With("AltColor1", 4, 1). + With("AltColor2", 5, 1) + +func initCritters() { + objClass := object.Class(14) + + genericDescriptions[objClass] = critterGenerics + + setSpecific(objClass, 3, cyberCritters) +} diff --git a/modding/hacked/ss1/content/object/objprop/CyberspaceProperties.go b/modding/hacked/ss1/content/object/objprop/CyberspaceProperties.go new file mode 100644 index 0000000..b17048f --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/CyberspaceProperties.go @@ -0,0 +1,13 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" +) + +var cyberColorScheme = interpreters.New(). + With("Color0", 0, 1). + With("Color1", 1, 1). + With("Color2", 2, 1). + With("Color3", 3, 1). + With("Color4", 4, 1). + With("Color5", 5, 1) diff --git a/modding/hacked/ss1/content/object/objprop/GenericProperties.go b/modding/hacked/ss1/content/object/objprop/GenericProperties.go new file mode 100644 index 0000000..b42bbcf --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/GenericProperties.go @@ -0,0 +1,15 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +// GenericProperties returns an interpreter specific for the given object class. +func GenericProperties(objClass object.Class, data []byte) *interpreters.Instance { + desc := genericDescriptions[objClass] + if desc == nil { + desc = interpreters.New() + } + return desc.For(data) +} diff --git a/modding/hacked/ss1/content/object/objprop/Grenades.go b/modding/hacked/ss1/content/object/objprop/Grenades.go new file mode 100644 index 0000000..9ef97b6 --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/Grenades.go @@ -0,0 +1,31 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var grenadeGenerics = interpreters.New(). + Refining("BasicWeapon", 0, 8, basicWeapon, interpreters.Always). + With("Touchiness", 8, 1). + With("BlastRadius", 9, 1). + With("BlastCoreRange", 10, 1). + With("BlastDamage", 11, 1). + With("AttackMass", 12, 1). + With("Flags", 13, 1).As(interpreters.Bitfield(map[uint32]string{ + 0x01: "Explode on contact", + 0x04: "Explode by timer", + 0x08: "Explode by vicinity"})) + +var timedExplosives = interpreters.New(). + With("MinimumTime", 0, 1). + With("MaximumTime", 1, 1). + With("RandomFactor", 2, 1) + +func initGrenades() { + objClass := object.Class(3) + + genericDescriptions[objClass] = grenadeGenerics + + setSpecific(objClass, 1, timedExplosives) +} diff --git a/modding/hacked/ss1/content/object/objprop/Guns.go b/modding/hacked/ss1/content/object/objprop/Guns.go new file mode 100644 index 0000000..2d2753c --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/Guns.go @@ -0,0 +1,64 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var basicWeapon = interpreters.New(). + With("Damage", 0, 2).As(interpreters.RangedValue(0, 0x7FFF)). + With("OffenceValue", 2, 1). + With("DamageType", 3, 1).As(damageType). + With("SpecialDamageType", 4, 1).As(specialDamageType). + With("ArmorPenetration", 7, 1) + +var weaponGenerics = interpreters.New(). + With("FireRate", 0, 1). + With("AmmoType", 1, 1).As(interpreters.Bitfield(map[uint32]string{ + 0x01: "AmmoType0", + 0x02: "AmmoType1", + 0x04: "AmmoType2", + 0x08: "AmmoType3", + 0xF0: "AmmoSubclass"})) + +var projectileWeapons = interpreters.New(). + Refining("BasicWeapon", 0, 8, basicWeapon, interpreters.Always). + With("ProjectileTravelSpeed", 8, 1). + With("Projectile", 9, 4).As(interpreters.SpecialValue("ObjectTriple")). + With("AttackMass", 13, 1). + With("AttackSpeed", 14, 2).As(interpreters.RangedValue(-10000, +10000)) + +var meleeWeapons = interpreters.New(). + Refining("BasicWeapon", 0, 8, basicWeapon, interpreters.Always). + With("EnergyUsage", 8, 1). + With("AttackMass", 9, 1). + With("Range", 10, 1). + With("AttackSpeed", 11, 2).As(interpreters.RangedValue(-10000, +10000)) + +var energyBeamWeapons = interpreters.New(). + Refining("BasicWeapon", 0, 8, basicWeapon, interpreters.Always). + With("MaxCharge", 8, 1). + With("AttackMass", 9, 1). + With("Range", 10, 1). + With("AttackSpeed", 11, 2).As(interpreters.RangedValue(-10000, +10000)) + +var energyProjectileWeapons = interpreters.New(). + Refining("BasicWeapon", 0, 8, basicWeapon, interpreters.Always). + With("MaxCharge", 8, 1). + With("AttackMass", 9, 1). + With("AttackSpeed", 10, 2).As(interpreters.RangedValue(-10000, +10000)). + With("ProjectileTravelSpeed", 12, 1). + With("Projectile", 13, 4).As(interpreters.SpecialValue("ObjectTriple")). + With("Flags", 17, 1).As(interpreters.Bitfield(map[uint32]string{ + 0x02: "Stun"})) + +func initGuns() { + objClass := object.Class(0) + + genericDescriptions[objClass] = weaponGenerics + + setSpecific(objClass, 2, projectileWeapons) + setSpecific(objClass, 3, meleeWeapons) + setSpecific(objClass, 4, energyBeamWeapons) + setSpecific(objClass, 5, energyProjectileWeapons) +} diff --git a/modding/hacked/ss1/content/object/objprop/Physics.go b/modding/hacked/ss1/content/object/objprop/Physics.go new file mode 100644 index 0000000..270141c --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/Physics.go @@ -0,0 +1,28 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var projectileGenerics = interpreters.New(). + With("Flags", 0, 1).As(interpreters.Bitfield(map[uint32]string{ + 0x01: "EmitLight", + 0x02: "BounceOffWalls", + 0x04: "BounceOffObjects", + 0x08: "BounceOffProjectiles"})) + +var cyberProjectiles = interpreters.New(). + Refining("ColorScheme", 0, 6, cyberColorScheme, interpreters.Always) + +func initPhysics() { + objClass := object.Class(2) + + genericDescriptions[objClass] = projectileGenerics + + setSpecificByType(objClass, 1, 9, cyberProjectiles) + setSpecificByType(objClass, 1, 10, cyberProjectiles) + setSpecificByType(objClass, 1, 11, cyberProjectiles) + setSpecificByType(objClass, 1, 12, cyberProjectiles) + setSpecificByType(objClass, 1, 13, cyberProjectiles) +} diff --git a/modding/hacked/ss1/content/object/objprop/SmallStuff.go b/modding/hacked/ss1/content/object/objprop/SmallStuff.go new file mode 100644 index 0000000..742df01 --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/SmallStuff.go @@ -0,0 +1,15 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var cyberItems = interpreters.New(). + Refining("ColorScheme", 0, 6, cyberColorScheme, interpreters.Always) + +func initSmallStuff() { + objClass := object.Class(8) + + setSpecific(objClass, 5, cyberItems) +} diff --git a/modding/hacked/ss1/content/object/objprop/SpecificProperties.go b/modding/hacked/ss1/content/object/objprop/SpecificProperties.go new file mode 100644 index 0000000..baa9d8b --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/SpecificProperties.go @@ -0,0 +1,18 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +// SpecificProperties returns an interpreter specific for the given object class and subclass. +func SpecificProperties(triple object.Triple, data []byte) *interpreters.Instance { + desc := specificDescriptions[triple] + if desc == nil { + desc = specificDescriptions[object.TripleFrom(int(triple.Class), int(triple.Subclass), anyObjectType)] + } + if desc == nil { + desc = interpreters.New() + } + return desc.For(data) +} diff --git a/modding/hacked/ss1/content/object/objprop/objprop.go b/modding/hacked/ss1/content/object/objprop/objprop.go new file mode 100644 index 0000000..f174741 --- /dev/null +++ b/modding/hacked/ss1/content/object/objprop/objprop.go @@ -0,0 +1,48 @@ +package objprop + +import ( + "github.com/inkyblackness/hacked/ss1/content/interpreters" + "github.com/inkyblackness/hacked/ss1/content/object" +) + +var genericDescriptions map[object.Class]*interpreters.Description +var specificDescriptions map[object.Triple]*interpreters.Description + +const anyObjectType = 0xFF + +var damageType = interpreters.Bitfield(map[uint32]string{ + 0x01: "Explosion", + 0x02: "Energy", + 0x04: "Magnetic", + 0x08: "Radiation", + 0x10: "Gas", + 0x20: "Tranquilizer", + 0x40: "Needle", + 0x80: "Bio", +}) + +var specialDamageType = interpreters.Bitfield(map[uint32]string{ + 0x0F: "Primary (double)", + 0xF0: "Super (quad)", +}) + +func init() { + genericDescriptions = make(map[object.Class]*interpreters.Description) + specificDescriptions = make(map[object.Triple]*interpreters.Description) + + initGuns() + initAmmo() + initPhysics() + initGrenades() + initSmallStuff() + initAnimating() + initCritters() +} + +func setSpecific(objClass object.Class, objSubclass int, desc *interpreters.Description) { + specificDescriptions[object.TripleFrom(int(objClass), objSubclass, anyObjectType)] = desc +} + +func setSpecificByType(objClass object.Class, objSubclass int, objType int, desc *interpreters.Description) { + specificDescriptions[object.TripleFrom(int(objClass), objSubclass, objType)] = desc +} diff --git a/modding/hacked/ss1/content/sound/SoundEffectCache.go b/modding/hacked/ss1/content/sound/SoundEffectCache.go new file mode 100644 index 0000000..46cfda8 --- /dev/null +++ b/modding/hacked/ss1/content/sound/SoundEffectCache.go @@ -0,0 +1,70 @@ +package sound + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/audio/voc" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// EffectCache retrieves audio samples stored as VOC files from a localizer and +// keeps them cached until they are invalidated. +type EffectCache struct { + localizer resource.Localizer + + sounds map[resource.Key]audio.L8 +} + +// NewEffectCache returns a new instance. +func NewEffectCache(localizer resource.Localizer) *EffectCache { + cache := &EffectCache{ + localizer: localizer, + + sounds: make(map[resource.Key]audio.L8), + } + return cache +} + +// InvalidateResources lets the cache remove any sounds from resources that are specified in the given slice. +func (cache *EffectCache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key := range cache.sounds { + if key.ID == id { + delete(cache.sounds, key) + } + } + } +} + +func (cache *EffectCache) cached(key resource.Key) (audio.L8, error) { + value, existing := cache.sounds[key] + if existing { + return value, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + view, err := selector.Select(key.ID.Plus(key.Index)) + if err != nil { + return audio.L8{}, err + } + if view.ContentType() != resource.Sound { + return audio.L8{}, resource.ErrWrongType(key, resource.Sound) + } + reader, err := view.Block(0) + if err != nil { + return audio.L8{}, err + } + sound, err := voc.Load(reader) + if err != nil { + return audio.L8{}, err + } + cache.sounds[key] = sound + return sound, nil +} + +// Audio retrieves and caches the underlying sound. +func (cache *EffectCache) Audio(key resource.Key) (audio.L8, error) { + cached, err := cache.cached(key) + if err != nil { + return audio.L8{}, err + } + return cached, nil +} diff --git a/modding/hacked/ss1/content/text/Blocked.go b/modding/hacked/ss1/content/text/Blocked.go new file mode 100644 index 0000000..c796c67 --- /dev/null +++ b/modding/hacked/ss1/content/text/Blocked.go @@ -0,0 +1,42 @@ +package text + +import "strings" + +const ( + // textLineLimit is the lowest number found by a cursory search. + // It appears that various texts have different limits. + textLineLimit = 79 +) + +// Blocked splits the given input string into a series of lines, usable for blocked serialization. +// It returns an array of strings that each are below a maximum line length. The list is terminated with +// one empty line (necessary for compound strings). +func Blocked(input string) []string { + var result []string + textLines := strings.Split(input, "\n") + resultLine := "" + addBlock := func() { + if len(resultLine) > 0 { + result = append(result, resultLine) + resultLine = "" + } + } + + for textLineIndex, textLine := range textLines { + words := strings.Split(textLine, " ") + for wordIndex, word := range words { + if wordIndex > 0 { + resultLine += " " + } + if (len(resultLine) + len(word)) > textLineLimit { + addBlock() + } + resultLine += word + } + if textLineIndex < (len(textLines) - 1) { + resultLine += "\n" + } + } + addBlock() + return append(result, "") +} diff --git a/modding/hacked/ss1/content/text/Blocked_test.go b/modding/hacked/ss1/content/text/Blocked_test.go new file mode 100644 index 0000000..8b0b298 --- /dev/null +++ b/modding/hacked/ss1/content/text/Blocked_test.go @@ -0,0 +1,36 @@ +package text_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/text" + + "github.com/stretchr/testify/assert" +) + +func TestSplitText(t *testing.T) { + tt := []struct { + input string + expected []string + }{ + {input: "", expected: []string{""}}, + {input: "a", expected: []string{"a", ""}}, + {input: "a", expected: []string{"a", ""}}, + {input: "b\n", expected: []string{"b\n", ""}}, + {input: " spacing! ", expected: []string{" spacing! ", ""}}, + {input: "terse1\n\n\nterse2", expected: []string{"terse1\n\n\nterse2", ""}}, + { + input: "aaaaaaaaa bbbbbbbbb ccccccccc ddddddddd eeeeeeeee fffffffff ggggggggg hhhhhhhhh iiiiiiiii jjjjjjjjj kkkkk", + expected: []string{ + "aaaaaaaaa bbbbbbbbb ccccccccc ddddddddd eeeeeeeee fffffffff ggggggggg hhhhhhhhh ", + "iiiiiiiii jjjjjjjjj kkkkk", + "", + }, + }, + } + + for _, tc := range tt { + result := text.Blocked(tc.input) + assert.Equal(t, tc.expected, result, "wrong result for <"+tc.input+">") + } +} diff --git a/modding/hacked/ss1/content/text/Cache.go b/modding/hacked/ss1/content/text/Cache.go new file mode 100644 index 0000000..6e88a05 --- /dev/null +++ b/modding/hacked/ss1/content/text/Cache.go @@ -0,0 +1,114 @@ +package text + +import ( + "io/ioutil" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +type keyResolver func(resource.Key) resource.Key +type textReader func(resource.Selector, resource.Key, Codepage) (string, error) + +// Cache retrieves texts from a localizer and keeps them decoded until they are invalidated. +type Cache struct { + cp Codepage + localizer resource.Localizer + reader textReader + + keyResolver keyResolver + texts map[resource.Key]string +} + +func newCache(cp Codepage, localizer resource.Localizer, keyResolver keyResolver, reader textReader) *Cache { + cache := &Cache{ + cp: cp, + localizer: localizer, + reader: reader, + + keyResolver: keyResolver, + texts: make(map[resource.Key]string), + } + return cache +} + +// NewLineCache returns a cache for single-block texts. +func NewLineCache(cp Codepage, localizer resource.Localizer) *Cache { + return newCache(cp, localizer, func(key resource.Key) resource.Key { return key }, readLine) +} + +// NewPageCache returns a cache for resource-based texts. +func NewPageCache(cp Codepage, localizer resource.Localizer) *Cache { + return newCache(cp, localizer, func(key resource.Key) resource.Key { + return resource.KeyOf(key.ID.Plus(key.Index), key.Lang, 0) + }, readPage) +} + +func readLine(selector resource.Selector, key resource.Key, cp Codepage) (string, error) { + view, err := selector.Select(key.ID) + if err != nil { + return "", err + } + if view.ContentType() != resource.Text { + return "", resource.ErrWrongType(key, resource.Text) + } + reader, err := view.Block(key.Index) + if err != nil { + return "", err + } + raw, err := ioutil.ReadAll(reader) + if err != nil { + return "", err + } + return cp.Decode(raw), nil +} + +func readPage(selector resource.Selector, key resource.Key, cp Codepage) (string, error) { + view, err := selector.Select(key.ID.Plus(key.Index)) + if err != nil { + return "", err + } + if view.ContentType() != resource.Text { + return "", resource.ErrWrongType(key, resource.Text) + } + blockCount := view.BlockCount() + value := "" + for block := 0; block < blockCount; block++ { + reader, err := view.Block(block) + if err != nil { + return "", err + } + raw, err := ioutil.ReadAll(reader) + if err != nil { + return "", err + } + value += cp.Decode(raw) + } + return value, nil +} + +// InvalidateResources lets the cache remove any texts from resources that are specified in the given slice. +func (cache *Cache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key := range cache.texts { + if key.ID == id { + delete(cache.texts, key) + } + } + } +} + +// Text retrieves and caches the text of given key. +func (cache *Cache) Text(key resource.Key) (string, error) { + cacheKey := cache.keyResolver(key) + value, existing := cache.texts[cacheKey] + if existing { + return value, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + value, err := cache.reader(selector, key, cache.cp) + if err != nil { + return "", err + } + cache.texts[cacheKey] = value + return value, nil +} diff --git a/modding/hacked/ss1/content/text/Cache_test.go b/modding/hacked/ss1/content/text/Cache_test.go new file mode 100644 index 0000000..dc8d744 --- /dev/null +++ b/modding/hacked/ss1/content/text/Cache_test.go @@ -0,0 +1,198 @@ +package text_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type CacheSuite struct { + suite.Suite + + localizedResources resource.LocalizedResourcesList + + cp text.Codepage + instance *text.Cache +} + +func TestCacheSuite(t *testing.T) { + suite.Run(t, new(CacheSuite)) +} + +func (suite *CacheSuite) SetupTest() { + suite.cp = text.DefaultCodepage() + suite.instance = nil +} + +func (suite *CacheSuite) TestTextReturnsValueIfOKForLineCache() { + suite.givenALineCache() + suite.whenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, "test"))) + suite.thenTextShouldReturn("test", resource.KeyOf(0x1000, resource.LangGerman, 0)) +} + +func (suite *CacheSuite) TestTextReturnsValueIfOKForPageCache() { + suite.givenAPageCache() + suite.whenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, "this is", " separated", " over lines"))) + suite.thenTextShouldReturn("this is separated over lines", resource.KeyOf(0x1000, resource.LangGerman, 0)) +} + +func (suite *CacheSuite) TestTextReturnsErrorIfResourceNotExistingForLineCache() { + suite.givenALineCache() + suite.whenResourcesAre() + suite.thenTextShouldReturnError(resource.KeyOf(0x1000, resource.LangGerman, 0)) +} + +func (suite *CacheSuite) TestTextReturnsErrorIfResourceNotExistingForPageCache() { + suite.givenAPageCache() + suite.whenResourcesAre() + suite.thenTextShouldReturnError(resource.KeyOf(0x1000, resource.LangGerman, 0)) +} + +func (suite *CacheSuite) TestTextReturnsErrorIfBlockNotExistingForLineCache() { + suite.givenALineCache() + suite.whenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, "zero", "one"))) + suite.thenTextShouldReturnError(resource.KeyOf(0x1000, resource.LangGerman, 2)) +} + +func (suite *CacheSuite) TestTextReturnsCachedValueIfPreviouslyRetrieved() { + key := resource.KeyOf(0x1000, resource.LangGerman, 0) + suite.givenALineCache() + suite.givenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, "test"))) + suite.givenTextWasRetrieved(key) + suite.whenResourcesAre() + suite.thenTextShouldReturn("test", key) +} + +func (suite *CacheSuite) TestTextTriesToReloadWhenCacheInvalidated() { + key := resource.KeyOf(0x1000, resource.LangGerman, 0) + suite.givenALineCache() + suite.givenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, "test"))) + suite.givenTextWasRetrieved(key) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(0x1000) + suite.thenTextShouldReturnError(key) +} + +func (suite *CacheSuite) TestInvalidationConsidersOnlyAffectedIDs() { + suite.givenALineCache() + key := resource.KeyOf(0x1000, resource.LangGerman, 0) + suite.givenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, "test"), suite.storing(0x1001, "other"))) + suite.givenTextWasRetrieved(key) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(0x1001) + suite.thenTextShouldReturn("test", key) +} + +func (suite *CacheSuite) TestTextReturnsErrorIfResourceIsNotATextForLineCache() { + suite.givenALineCache() + suite.whenResourcesAre(suite.someLocalizedResources(resource.LangDefault, + suite.storingNonText(0x1000))) + suite.thenTextShouldReturnError(resource.KeyOf(0x1000, resource.LangDefault, 0)) +} + +func (suite *CacheSuite) TestTextReturnsErrorIfResourceIsNotATextForPageCache() { + suite.givenAPageCache() + suite.whenResourcesAre(suite.someLocalizedResources(resource.LangDefault, + suite.storingNonText(0x1000))) + suite.thenTextShouldReturnError(resource.KeyOf(0x1000, resource.LangDefault, 0)) +} + +func (suite *CacheSuite) givenALineCache() { + suite.instance = text.NewLineCache(suite.cp, suite) +} + +func (suite *CacheSuite) givenAPageCache() { + suite.instance = text.NewPageCache(suite.cp, suite) +} + +func (suite *CacheSuite) givenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *CacheSuite) givenTextWasRetrieved(key resource.Key) { + _, err := suite.instance.Text(key) + assert.Nil(suite.T(), err, "no error expected") +} + +func (suite *CacheSuite) whenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *CacheSuite) whenCacheResourcesAreInvalidated(ids ...resource.ID) { + suite.instance.InvalidateResources(ids) +} + +func (suite *CacheSuite) thenTextShouldReturn(expected string, key resource.Key) { + result, err := suite.instance.Text(key) + require.Nil(suite.T(), err, "No error expected") + assert.Equal(suite.T(), expected, result, "Texts don't match") +} + +func (suite *CacheSuite) thenTextShouldReturnError(key resource.Key) { + _, err := suite.instance.Text(key) + require.NotNil(suite.T(), err, "Error expected") +} + +func (suite *CacheSuite) someLocalizedResources(lang resource.Language, modifiers ...func(*resource.Store)) resource.LocalizedResources { + var store resource.Store + for _, modifier := range modifiers { + modifier(&store) + } + return resource.LocalizedResources{ + ID: "unnamed", + Language: lang, + Viewer: store, + } +} + +func (suite *CacheSuite) storing(id int, lines ...string) func(*resource.Store) { + data := make([][]byte, len(lines)) + for i, line := range lines { + data[i] = suite.cp.Encode(line) + } + return func(store *resource.Store) { + _ = store.Put(resource.ID(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Text, + Compound: len(data) != 1, + }, + Blocks: resource.BlocksFrom(data), + }) + } +} + +func (suite *CacheSuite) storingNonText(id int) func(*resource.Store) { + return func(store *resource.Store) { + _ = store.Put(resource.ID(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Sound, + Compound: false, + }, + Blocks: resource.BlocksFrom([][]byte{{}}), + }) + } +} + +func (suite *CacheSuite) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + From: suite.localizedResources, + Lang: lang, + } +} diff --git a/modding/hacked/ss1/content/text/Codepage.go b/modding/hacked/ss1/content/text/Codepage.go new file mode 100644 index 0000000..5fc22b2 --- /dev/null +++ b/modding/hacked/ss1/content/text/Codepage.go @@ -0,0 +1,10 @@ +package text + +// Codepage wraps the methods for serializing strings. The encoded byte array is expected +// to be terminated by one 0x00 byte. +type Codepage interface { + // Encode converts the provided string to a byte array. It appends one 0x00 byte at the end. + Encode(value string) []byte + // Decode converts the provided byte slice to a string. It ignores trailing 0x00 bytes. + Decode(data []byte) string +} diff --git a/modding/hacked/ss1/content/text/DefaultCodepage.go b/modding/hacked/ss1/content/text/DefaultCodepage.go new file mode 100644 index 0000000..c316496 --- /dev/null +++ b/modding/hacked/ss1/content/text/DefaultCodepage.go @@ -0,0 +1,34 @@ +package text + +var cp437ToRune = [256]rune{ + 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F, + 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017, 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F, + 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027, 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F, + 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037, 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F, + 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047, 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F, + 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057, 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F, + 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067, 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F, + 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077, 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F, + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, + 0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, + 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA, 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, + 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, 0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229, + 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0, +} + +// DefaultCodepage returns a Codepage instance that represents the one used for the resources. +// It is based on the Code Page 437 ( https://en.wikipedia.org/wiki/Code_page_437 ). +func DefaultCodepage() Codepage { + cp437ToByte := make(map[rune]byte) + + for i, r := range &cp437ToRune { + cp437ToByte[r] = byte(i) + } + + cp := &tabledCodepage{tableToRune: cp437ToRune[:], tableToByte: cp437ToByte} + + return cp +} diff --git a/modding/hacked/ss1/content/text/DefaultCodepage_test.go b/modding/hacked/ss1/content/text/DefaultCodepage_test.go new file mode 100644 index 0000000..57c7f32 --- /dev/null +++ b/modding/hacked/ss1/content/text/DefaultCodepage_test.go @@ -0,0 +1,27 @@ +package text_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/text" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultCodepageEncode(t *testing.T) { + result := text.DefaultCodepage().Encode("ä") + + assert.Equal(t, []byte{132, 0x00}, result) +} + +func TestDefaultCodepageDecode(t *testing.T) { + result := text.DefaultCodepage().Decode([]byte{144, 225, 0x00}) + + assert.Equal(t, "Éß", result) +} + +func TestDefaultCodepageMapsUnknownCharacterToQuestionMark(t *testing.T) { + result := text.DefaultCodepage().Encode("„quotedâ€�") + + assert.Equal(t, []byte{0x3F, 0x71, 0x75, 0x6F, 0x74, 0x65, 0x64, 0x3F, 0x00}, result) +} diff --git a/modding/hacked/ss1/content/text/ElectronicMessage.go b/modding/hacked/ss1/content/text/ElectronicMessage.go new file mode 100644 index 0000000..df00a1a --- /dev/null +++ b/modding/hacked/ss1/content/text/ElectronicMessage.go @@ -0,0 +1,201 @@ +package text + +import ( + "fmt" + "io/ioutil" + "regexp" + "strconv" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +const ( + metaExpressionInterrupt = "(t)" + metaExpressionNextMessage = "(?:i([0-9a-fA-F]{2}))" + metaExpressionColorIndex = "(?:c([0-9a-fA-F]{2}))" + metaExpressionLeftDisplay = "([0-9]+)" + metaExpressionRightDisplay = "(?:,[ ]*([0-9]+))" +) + +var metaExpression = regexp.MustCompile("^[ ]*(?:(?:" + + metaExpressionInterrupt + "|" + + metaExpressionNextMessage + "|" + + metaExpressionColorIndex + "|" + + metaExpressionLeftDisplay + "|" + + metaExpressionRightDisplay + ")[ ]*)*$") + +type metaStringError struct { + MetaString string +} + +func (err metaStringError) Error() string { + return fmt.Sprintf("failed to parse meta string: '%s'", err.MetaString) +} + +type blockError struct { + Action string + Index int + Nested error +} + +func (err blockError) Error() string { + return fmt.Sprintf("failed to %s block %d: %v", err.Action, err.Index, err.Nested) +} + +func (err blockError) Unwrap() error { + return err.Nested +} + +// ElectronicMessage describes one message. +type ElectronicMessage struct { + // NextMessage identifies the message that should follow this one. -1 for none. + NextMessage int + // IsInterrupt marks messages that are the ones following another. + IsInterrupt bool + // ColorIndex is into the palette for the sender text. -1 for default. + ColorIndex int + // LeftDisplay indicates the image to show in the left MFD. -1 for none. + LeftDisplay int + // RightDisplay indicates the image to show in the right MFD. -1 for none. + RightDisplay int + + // Title of the message. + Title string + // Sender of the message. + Sender string + // Subject of the message. + Subject string + // VerboseText the long form. + VerboseText string + // TerseText the short form. + TerseText string +} + +// EmptyElectronicMessage returns an instance of an empty electronic message. +func EmptyElectronicMessage() ElectronicMessage { + message := ElectronicMessage{ + NextMessage: -1, + ColorIndex: -1, + LeftDisplay: -1, + RightDisplay: -1, + } + + return message +} + +// DecodeElectronicMessage tries to decode a message from given block holder. +func DecodeElectronicMessage(cp Codepage, provider resource.BlockProvider) (message ElectronicMessage, err error) { + blockIndex := 0 + nextBlockString := func() (line string) { + if err != nil { + return + } + if blockIndex < provider.BlockCount() { + blockReader, blockErr := provider.Block(blockIndex) + if blockErr != nil { + err = blockError{ + Action: "access", + Index: blockIndex, + Nested: blockErr, + } + return + } + data, dataErr := ioutil.ReadAll(blockReader) + if dataErr != nil { + err = blockError{ + Action: "read data from", + Index: blockIndex, + Nested: dataErr, + } + } + line = cp.Decode(data) + blockIndex++ + } + return + } + nextText := func() (text string) { + for line := nextBlockString(); len(line) > 0; line = nextBlockString() { + text += line + } + return + } + + metaString := nextBlockString() + metaData := metaExpression.FindStringSubmatch(metaString) + parseInt := func(metaIndex int, base, bits int) (result int) { + var value uint64 + result = -1 + if (err == nil) && (len(metaData[metaIndex]) > 0) { + value, err = strconv.ParseUint(metaData[metaIndex], base, bits) + if err == nil { + result = int(value) + } + } + return + } + + message = EmptyElectronicMessage() + if (len(metaData) == 0) || (len(metaData[0]) != len(metaString)) { + err = metaStringError{MetaString: metaString} + } + if (err == nil) && (len(metaData[1]) > 0) { + message.IsInterrupt = true + } + message.NextMessage = parseInt(2, 16, 16) + message.ColorIndex = parseInt(3, 16, 8) + message.LeftDisplay = parseInt(4, 10, 16) + message.RightDisplay = parseInt(5, 10, 16) + message.Title = nextBlockString() + message.Sender = nextBlockString() + message.Subject = nextBlockString() + message.VerboseText = nextText() + message.TerseText = nextText() + + return +} + +// Encode serializes the message into a block holder. +func (message ElectronicMessage) Encode(cp Codepage) [][]byte { + var blocks [][]byte + + blocks = append(blocks, cp.Encode(message.metaString())) + blocks = append(blocks, cp.Encode(message.Title)) + blocks = append(blocks, cp.Encode(message.Sender)) + blocks = append(blocks, cp.Encode(message.Subject)) + for _, line := range Blocked(message.VerboseText) { + blocks = append(blocks, cp.Encode(line)) + } + for _, line := range Blocked(message.TerseText) { + blocks = append(blocks, cp.Encode(line)) + } + + return blocks +} + +func (message ElectronicMessage) metaString() string { + result := "" + add := func(sep, part string) { + if len(result) > 0 { + result += sep + } + result += part + } + + if message.IsInterrupt { + add("", "t") + } + if message.NextMessage >= 0 { + add(" ", fmt.Sprintf("i%02X", message.NextMessage)) + } + if message.ColorIndex >= 0 { + add(" ", fmt.Sprintf("c%02X", message.ColorIndex)) + } + if message.LeftDisplay >= 0 { + add(" ", fmt.Sprintf("%d", message.LeftDisplay)) + } + if message.RightDisplay >= 0 { + add("", fmt.Sprintf(",%d", message.RightDisplay)) + } + + return result +} diff --git a/modding/hacked/ss1/content/text/ElectronicMessageCache.go b/modding/hacked/ss1/content/text/ElectronicMessageCache.go new file mode 100644 index 0000000..03241b6 --- /dev/null +++ b/modding/hacked/ss1/content/text/ElectronicMessageCache.go @@ -0,0 +1,58 @@ +package text + +import ( + "github.com/inkyblackness/hacked/ss1/resource" +) + +// ElectronicMessageCache retrieves messages from a localizer and keeps them decoded until they are invalidated. +type ElectronicMessageCache struct { + cp Codepage + localizer resource.Localizer + + messages map[resource.Key]ElectronicMessage +} + +// NewElectronicMessageCache returns a new instance. +func NewElectronicMessageCache(cp Codepage, localizer resource.Localizer) *ElectronicMessageCache { + cache := &ElectronicMessageCache{ + cp: cp, + localizer: localizer, + + messages: make(map[resource.Key]ElectronicMessage), + } + return cache +} + +// InvalidateResources lets the cache remove any texts from resources that are specified in the given slice. +func (cache *ElectronicMessageCache) InvalidateResources(ids []resource.ID) { + for _, id := range ids { + for key := range cache.messages { + if key.ID == id { + delete(cache.messages, key) + } + } + } +} + +// Message retrieves and caches the message of given key. +func (cache *ElectronicMessageCache) Message(key resource.Key) (ElectronicMessage, error) { + cacheKey := resource.KeyOf(key.ID.Plus(key.Index), key.Lang, 0) + value, existing := cache.messages[cacheKey] + if existing { + return value, nil + } + selector := cache.localizer.LocalizedResources(key.Lang) + view, err := selector.Select(cacheKey.ID) + if err != nil { + return EmptyElectronicMessage(), err + } + if (view.ContentType() != resource.Text) || !view.Compound() { + return EmptyElectronicMessage(), resource.ErrWrongType(key, resource.Text) + } + value, err = DecodeElectronicMessage(cache.cp, view) + if err != nil { + return EmptyElectronicMessage(), err + } + cache.messages[cacheKey] = value + return value, nil +} diff --git a/modding/hacked/ss1/content/text/ElectronicMessageCache_test.go b/modding/hacked/ss1/content/text/ElectronicMessageCache_test.go new file mode 100644 index 0000000..2bfcfaa --- /dev/null +++ b/modding/hacked/ss1/content/text/ElectronicMessageCache_test.go @@ -0,0 +1,189 @@ +package text_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ElectronicMessageCacheSuite struct { + suite.Suite + + localizedResources resource.LocalizedResourcesList + + cp text.Codepage + instance *text.ElectronicMessageCache +} + +func TestElectronicMessageCacheSuite(t *testing.T) { + suite.Run(t, new(ElectronicMessageCacheSuite)) +} + +func (suite *ElectronicMessageCacheSuite) SetupTest() { + suite.cp = text.DefaultCodepage() + suite.instance = nil +} + +func (suite *ElectronicMessageCacheSuite) TestMessageReturnsValueIfOK() { + suite.givenACache() + suite.whenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, func(msg text.ElectronicMessage) text.ElectronicMessage { + msg.VerboseText = "123" + msg.TerseText = "terse" + return msg + }))) + suite.thenMessageShouldReturn(resource.KeyOf(0x1000, resource.LangGerman, 0), func(msg text.ElectronicMessage) { + assert.Equal(suite.T(), "123", msg.VerboseText) + assert.Equal(suite.T(), "terse", msg.TerseText) + }) +} + +func (suite *ElectronicMessageCacheSuite) TestTextReturnsErrorIfResourceNotExisting() { + suite.givenACache() + suite.whenResourcesAre() + suite.thenMessageShouldReturnError(resource.KeyOf(0x1000, resource.LangGerman, 0)) +} + +func (suite *ElectronicMessageCacheSuite) TestTextReturnsCachedValueIfPreviouslyRetrieved() { + key := resource.KeyOf(0x1000, resource.LangGerman, 0) + suite.givenACache() + suite.givenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, func(msg text.ElectronicMessage) text.ElectronicMessage { + msg.VerboseText = "older text" + msg.TerseText = "terse text" + return msg + }))) + suite.givenMessageWasRetrieved(key) + suite.whenResourcesAre() + suite.thenMessageShouldReturn(key, func(msg text.ElectronicMessage) { + assert.Equal(suite.T(), "older text", msg.VerboseText) + assert.Equal(suite.T(), "terse text", msg.TerseText) + }) +} + +func (suite *ElectronicMessageCacheSuite) TestTextTriesToReloadWhenCacheInvalidated() { + key := resource.KeyOf(0x1000, resource.LangGerman, 0) + suite.givenACache() + suite.givenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, func(msg text.ElectronicMessage) text.ElectronicMessage { + msg.VerboseText = "12" + msg.TerseText = "20" + return msg + }))) + suite.givenMessageWasRetrieved(key) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(0x1000) + suite.thenMessageShouldReturnError(key) +} + +func (suite *ElectronicMessageCacheSuite) TestInvalidationConsidersOnlyAffectedIDs() { + suite.givenACache() + key := resource.KeyOf(0x1000, resource.LangGerman, 0) + suite.givenResourcesAre( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, func(msg text.ElectronicMessage) text.ElectronicMessage { + msg.VerboseText = "A" + return msg + }), + suite.storing(0x1001, func(msg text.ElectronicMessage) text.ElectronicMessage { + msg.VerboseText = "B" + return msg + }))) + suite.givenMessageWasRetrieved(key) + suite.givenResourcesAre() + suite.whenCacheResourcesAreInvalidated(0x1001) + suite.thenMessageShouldReturn(key, func(msg text.ElectronicMessage) { + assert.Equal(suite.T(), "A", msg.VerboseText) + }) +} + +func (suite *ElectronicMessageCacheSuite) TestTextReturnsErrorIfResourceIsNotATextForLineCache() { + suite.givenACache() + suite.whenResourcesAre(suite.someLocalizedResources(resource.LangDefault, + suite.storingNonText(0x1000))) + suite.thenMessageShouldReturnError(resource.KeyOf(0x1000, resource.LangDefault, 0)) +} + +func (suite *ElectronicMessageCacheSuite) givenACache() { + suite.instance = text.NewElectronicMessageCache(suite.cp, suite) +} + +func (suite *ElectronicMessageCacheSuite) givenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *ElectronicMessageCacheSuite) givenMessageWasRetrieved(key resource.Key) { + _, err := suite.instance.Message(key) + assert.Nil(suite.T(), err, "no error expected") +} + +func (suite *ElectronicMessageCacheSuite) whenResourcesAre(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *ElectronicMessageCacheSuite) whenCacheResourcesAreInvalidated(ids ...resource.ID) { + suite.instance.InvalidateResources(ids) +} + +func (suite *ElectronicMessageCacheSuite) thenMessageShouldReturn(key resource.Key, validator func(msg text.ElectronicMessage)) { + result, err := suite.instance.Message(key) + require.Nil(suite.T(), err, "No error expected") + validator(result) +} + +func (suite *ElectronicMessageCacheSuite) thenMessageShouldReturnError(key resource.Key) { + _, err := suite.instance.Message(key) + require.NotNil(suite.T(), err, "Error expected") +} + +func (suite *ElectronicMessageCacheSuite) someLocalizedResources(lang resource.Language, modifiers ...func(*resource.Store)) resource.LocalizedResources { + var store resource.Store + for _, modifier := range modifiers { + modifier(&store) + } + return resource.LocalizedResources{ + ID: "unnamed", + Language: lang, + Viewer: store, + } +} + +func (suite *ElectronicMessageCacheSuite) storing(id int, modifier func(msg text.ElectronicMessage) text.ElectronicMessage) func(*resource.Store) { + data := modifier(text.EmptyElectronicMessage()).Encode(suite.cp) + return func(store *resource.Store) { + _ = store.Put(resource.ID(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Text, + Compound: true, + }, + Blocks: resource.BlocksFrom(data), + }) + } +} + +func (suite *ElectronicMessageCacheSuite) storingNonText(id int) func(*resource.Store) { + return func(store *resource.Store) { + _ = store.Put(resource.ID(id), resource.Resource{ + Properties: resource.Properties{ + ContentType: resource.Sound, + Compound: false, + }, + Blocks: resource.BlocksFrom([][]byte{{}}), + }) + } +} + +func (suite *ElectronicMessageCacheSuite) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + From: suite.localizedResources, + Lang: lang, + } +} diff --git a/modding/hacked/ss1/content/text/ElectronicMessage_test.go b/modding/hacked/ss1/content/text/ElectronicMessage_test.go new file mode 100644 index 0000000..6ec7ac2 --- /dev/null +++ b/modding/hacked/ss1/content/text/ElectronicMessage_test.go @@ -0,0 +1,262 @@ +package text_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ElectronicMessageSuite struct { + suite.Suite + cp text.Codepage +} + +func TestElectronicMessageSuite(t *testing.T) { + suite.Run(t, new(ElectronicMessageSuite)) +} + +func (suite *ElectronicMessageSuite) SetupTest() { + suite.cp = text.DefaultCodepage() +} + +func (suite *ElectronicMessageSuite) TestEncodeBasicMessage() { + message := text.EmptyElectronicMessage() + + message.Title = "1" + message.Sender = "2" + message.Subject = "3" + message.VerboseText = "4" + message.TerseText = "5" + + encoded := message.Encode(suite.cp) + + suite.verifyBlock(0, encoded, []byte{0x00}) + suite.verifyBlock(1, encoded, []byte{0x31, 0x00}) + suite.verifyBlock(2, encoded, []byte{0x32, 0x00}) + suite.verifyBlock(3, encoded, []byte{0x33, 0x00}) + suite.verifyBlock(4, encoded, []byte{0x34, 0x00}) + suite.verifyBlock(5, encoded, []byte{0x00}) + suite.verifyBlock(6, encoded, []byte{0x35, 0x00}) + suite.verifyBlock(7, encoded, []byte{0x00}) +} + +func (suite *ElectronicMessageSuite) TestEncodeMeta_A() { + message := text.EmptyElectronicMessage() + + message.NextMessage = 0x20 + message.ColorIndex = 0x13 + message.LeftDisplay = 30 + message.RightDisplay = 40 + + encoded := message.Encode(suite.cp) + + require.NotNil(suite.T(), encoded) + require.Equal(suite.T(), true, len(encoded) > 0) + suite.verifyBlock(0, encoded, suite.cp.Encode("i20 c13 30,40")) +} + +func (suite *ElectronicMessageSuite) TestEncodeMeta_B() { + message := text.EmptyElectronicMessage() + + message.IsInterrupt = true + message.LeftDisplay = 31 + + encoded := message.Encode(suite.cp) + + require.NotNil(suite.T(), encoded) + require.Equal(suite.T(), true, len(encoded) > 0) + suite.verifyBlock(0, encoded, suite.cp.Encode("t 31")) +} + +func (suite *ElectronicMessageSuite) TestEncodeMeta_C() { + message := text.EmptyElectronicMessage() + + message.IsInterrupt = true + + encoded := message.Encode(suite.cp) + + require.NotNil(suite.T(), encoded) + require.Equal(suite.T(), true, len(encoded) > 0) + suite.verifyBlock(0, encoded, suite.cp.Encode("t")) +} + +func (suite *ElectronicMessageSuite) TestEncodeCreatesNewBlocksPerNewLine() { + message := text.EmptyElectronicMessage() + + message.VerboseText = "line1\n\n\nline2" + message.TerseText = "terse1\n\n\nterse2" + + encoded := message.Encode(suite.cp) + + require.NotNil(suite.T(), encoded) + require.Equal(suite.T(), true, len(encoded) > 0) + suite.verifyBlock(4, encoded, suite.cp.Encode("line1\n\n\nline2")) + suite.verifyBlock(6, encoded, suite.cp.Encode("terse1\n\n\nterse2")) +} + +func (suite *ElectronicMessageSuite) TestEncodeBreaksUpLinesAfterLimitCharacters() { + message := text.EmptyElectronicMessage() + + message.VerboseText = "aaaaaaaaa bbbbbbbbb ccccccccc ddddddddd eeeeeeeee fffffffff ggggggggg hhhhhhhhh iiiiiiiii jjjjjjjjj kkkkk" + + encoded := message.Encode(suite.cp) + + require.NotNil(suite.T(), encoded) + require.Equal(suite.T(), true, len(encoded) > 0) + suite.verifyBlock(4, encoded, + suite.cp.Encode("aaaaaaaaa bbbbbbbbb ccccccccc ddddddddd eeeeeeeee fffffffff ggggggggg hhhhhhhhh ")) + suite.verifyBlock(5, encoded, + suite.cp.Encode("iiiiiiiii jjjjjjjjj kkkkk")) +} + +func (suite *ElectronicMessageSuite) TestDecodeMeta() { + message, err := text.DecodeElectronicMessage(suite.cp, suite.holderWithMeta("i20 c13 30,40")) + + require.Nil(suite.T(), err) + require.NotNil(suite.T(), message) + assert.Equal(suite.T(), 0x20, message.NextMessage) + assert.Equal(suite.T(), 0x13, message.ColorIndex) + assert.Equal(suite.T(), 30, message.LeftDisplay) + assert.Equal(suite.T(), 40, message.RightDisplay) +} + +func (suite *ElectronicMessageSuite) TestDecodeMeta_Failure() { + _, err := text.DecodeElectronicMessage(suite.cp, suite.holderWithMeta("i20 c 13 30,40")) + + assert.NotNil(suite.T(), err) +} + +func (suite *ElectronicMessageSuite) TestDecodeMetaColorIs8BitUnsigned() { + message, err := text.DecodeElectronicMessage(suite.cp, suite.holderWithMeta("cD1")) + + require.Nil(suite.T(), err) + require.NotNil(suite.T(), message) + assert.Equal(suite.T(), 0xD1, message.ColorIndex) +} + +func (suite *ElectronicMessageSuite) TestDecodeMessage() { + message, err := text.DecodeElectronicMessage(suite.cp, suite.holderWithMeta("10")) + + require.Nil(suite.T(), err) + require.NotNil(suite.T(), message) + assert.Equal(suite.T(), "title", message.Title) + assert.Equal(suite.T(), "sender", message.Sender) + assert.Equal(suite.T(), "subject", message.Subject) + assert.Equal(suite.T(), "verbose", message.VerboseText) + assert.Equal(suite.T(), "terse", message.TerseText) +} + +func (suite *ElectronicMessageSuite) TestDecodeMessageIsPossibleForVanillaStubMails() { + message, err := text.DecodeElectronicMessage(suite.cp, suite.vanillaStubMail()) + + require.Nil(suite.T(), err) + require.NotNil(suite.T(), message) + assert.Equal(suite.T(), "", message.Title) + assert.Equal(suite.T(), "", message.Sender) + assert.Equal(suite.T(), "", message.Subject) + assert.Equal(suite.T(), "stub emailstub email", message.VerboseText) + assert.Equal(suite.T(), "", message.TerseText) +} + +func (suite *ElectronicMessageSuite) TestDecodeMessageIsPossibleForMissingTerminatingLine() { + message, err := text.DecodeElectronicMessage(suite.cp, suite.holderWithMissingTerminatingLine()) + + require.Nil(suite.T(), err) + require.NotNil(suite.T(), message) + assert.Equal(suite.T(), "title", message.Title) + assert.Equal(suite.T(), "sender", message.Sender) + assert.Equal(suite.T(), "subject", message.Subject) + assert.Equal(suite.T(), "verbose text", message.VerboseText) + assert.Equal(suite.T(), "terse text", message.TerseText) +} + +func (suite *ElectronicMessageSuite) TestRecodeMessage() { + inMessage := text.EmptyElectronicMessage() + inMessage.IsInterrupt = true + inMessage.NextMessage = 0x10 + inMessage.ColorIndex = 0x20 + inMessage.LeftDisplay = 40 + inMessage.RightDisplay = 50 + inMessage.VerboseText = "abcd\nefgh\nsome" + inMessage.TerseText = "\n" + + data := inMessage.Encode(suite.cp) + outMessage, err := text.DecodeElectronicMessage(suite.cp, resource.BlocksFrom(data)) + + require.Nil(suite.T(), err) + require.NotNil(suite.T(), outMessage) + assert.Equal(suite.T(), inMessage, outMessage) +} + +func (suite *ElectronicMessageSuite) TestRecodeMessageWithMultipleNewLines() { + inMessage := text.EmptyElectronicMessage() + inMessage.IsInterrupt = true + inMessage.NextMessage = 0x10 + inMessage.ColorIndex = 0x20 + inMessage.LeftDisplay = 40 + inMessage.RightDisplay = 50 + inMessage.VerboseText = "first\n\n\nsecond" + inMessage.TerseText = "terse\n" + + data := inMessage.Encode(suite.cp) + outMessage, err := text.DecodeElectronicMessage(suite.cp, resource.BlocksFrom(data)) + + require.Nil(suite.T(), err) + require.NotNil(suite.T(), outMessage) + assert.Equal(suite.T(), inMessage, outMessage) +} + +func (suite *ElectronicMessageSuite) verifyBlock(index int, blocks [][]byte, expected []byte) { + require.True(suite.T(), len(blocks) > index, "too few blocks") + assert.Equal(suite.T(), expected, blocks[index]) +} + +func (suite *ElectronicMessageSuite) holderWithMeta(meta string) resource.BlockProvider { + data := [][]byte{ + suite.cp.Encode(meta), + suite.cp.Encode("title"), + suite.cp.Encode("sender"), + suite.cp.Encode("subject"), + suite.cp.Encode("verbose"), + suite.cp.Encode(""), + suite.cp.Encode("terse"), + suite.cp.Encode("")} + + return resource.BlocksFrom(data) +} + +func (suite *ElectronicMessageSuite) vanillaStubMail() resource.BlockProvider { + // The string resources contain a few mails which aren't used. + // They are missing the terminating line for the verbose text. + data := [][]byte{ + suite.cp.Encode(""), + suite.cp.Encode(""), + suite.cp.Encode(""), + suite.cp.Encode(""), + suite.cp.Encode("stub email"), + suite.cp.Encode("stub email"), + suite.cp.Encode("")} + + return resource.BlocksFrom(data) +} + +func (suite *ElectronicMessageSuite) holderWithMissingTerminatingLine() resource.BlockProvider { + // This case is encountered once in gerstrng.res + data := [][]byte{ + suite.cp.Encode(""), + suite.cp.Encode("title"), + suite.cp.Encode("sender"), + suite.cp.Encode("subject"), + suite.cp.Encode("verbose "), + suite.cp.Encode("text"), + suite.cp.Encode(""), + suite.cp.Encode("terse "), + suite.cp.Encode("text")} + + return resource.BlocksFrom(data) +} diff --git a/modding/hacked/ss1/content/text/TabledCodepage.go b/modding/hacked/ss1/content/text/TabledCodepage.go new file mode 100644 index 0000000..7a4dd62 --- /dev/null +++ b/modding/hacked/ss1/content/text/TabledCodepage.go @@ -0,0 +1,33 @@ +package text + +type tabledCodepage struct { + tableToRune []rune + tableToByte map[rune]byte +} + +func (cp *tabledCodepage) Encode(value string) []byte { + result := make([]byte, 0, len(value)+1) + + for _, c := range value { + mapped, known := cp.tableToByte[c] + if !known { + mapped = cp.tableToByte['?'] + } + result = append(result, mapped) + } + result = append(result, 0x00) + + return result +} + +func (cp *tabledCodepage) Decode(data []byte) string { + runes := make([]rune, 0, len(data)) + + for _, value := range data { + if value != 0x00 { + runes = append(runes, cp.tableToRune[value]) + } + } + + return string(runes) +} diff --git a/modding/hacked/ss1/content/texture/Constants.go b/modding/hacked/ss1/content/texture/Constants.go new file mode 100644 index 0000000..97e82ee --- /dev/null +++ b/modding/hacked/ss1/content/texture/Constants.go @@ -0,0 +1,5 @@ +package texture + +const ( + propertiesFileVersion uint32 = 0x00000009 +) diff --git a/modding/hacked/ss1/content/texture/Properties.go b/modding/hacked/ss1/content/texture/Properties.go new file mode 100644 index 0000000..c4b4c4c --- /dev/null +++ b/modding/hacked/ss1/content/texture/Properties.go @@ -0,0 +1,35 @@ +package texture + +import "github.com/inkyblackness/hacked/ss1/serial" + +const ( + // PropertiesSize specifies, in bytes, the length a properties structure has. + PropertiesSize = 11 +) + +// Properties describe additional information about a texture. +type Properties struct { + _ byte + _ byte + _ int16 + DistanceModifier int16 + Climbable byte + _ byte + + TransparencyControl TransparencyControl + + AnimationGroup byte + AnimationIndex byte +} + +// PropertiesList implements serial.Codable to serialize a set of texture properties. +type PropertiesList []Properties + +// Code serializes the list with the provided coder. +func (list PropertiesList) Code(coder serial.Coder) { + version := propertiesFileVersion + coder.Code(&version) + for i := 0; i < len(list); i++ { + coder.Code(&list[i]) + } +} diff --git a/modding/hacked/ss1/content/texture/Properties_test.go b/modding/hacked/ss1/content/texture/Properties_test.go new file mode 100644 index 0000000..db65a0f --- /dev/null +++ b/modding/hacked/ss1/content/texture/Properties_test.go @@ -0,0 +1,15 @@ +package texture_test + +import ( + "encoding/binary" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/content/texture" +) + +func TestPropertiesSize(t *testing.T) { + size := binary.Size(texture.Properties{}) + assert.Equal(t, texture.PropertiesSize, size) +} diff --git a/modding/hacked/ss1/content/texture/TransparencyControl.go b/modding/hacked/ss1/content/texture/TransparencyControl.go new file mode 100644 index 0000000..f5c1ed9 --- /dev/null +++ b/modding/hacked/ss1/content/texture/TransparencyControl.go @@ -0,0 +1,33 @@ +package texture + +import "fmt" + +// TransparencyControl describes how a texture should be displayed. +type TransparencyControl byte + +var transparencyControlNames = []string{ + "Regular", + "Space", + "SpaceBackground", +} + +func (ctrl TransparencyControl) String() string { + if int(ctrl) < len(transparencyControlNames) { + return transparencyControlNames[ctrl] + } + return fmt.Sprintf("Unknown%02X", int(ctrl)) +} + +const ( + // TransparencyControlRegular describes a typical texture. + TransparencyControlRegular TransparencyControl = 0x00 + // TransparencyControlSpace marks space texture, which ignores bitmap data. + TransparencyControlSpace TransparencyControl = 0x01 + // TransparencyControlSpaceBackground draws space for palette index 0x00. + TransparencyControlSpaceBackground TransparencyControl = 0x02 +) + +// TransparencyControls returns all known transparency control values. +func TransparencyControls() []TransparencyControl { + return []TransparencyControl{TransparencyControlRegular, TransparencyControlSpace, TransparencyControlSpaceBackground} +} diff --git a/modding/hacked/ss1/content/texture/TransparencyControl_test.go b/modding/hacked/ss1/content/texture/TransparencyControl_test.go new file mode 100644 index 0000000..45b0d88 --- /dev/null +++ b/modding/hacked/ss1/content/texture/TransparencyControl_test.go @@ -0,0 +1,31 @@ +package texture_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/content/texture" + + "github.com/stretchr/testify/assert" +) + +func TestTransparencyControlString(t *testing.T) { + tt := []struct { + ctrl texture.TransparencyControl + expected string + }{ + {ctrl: texture.TransparencyControlRegular, expected: "Regular"}, + {ctrl: texture.TransparencyControlSpace, expected: "Space"}, + {ctrl: texture.TransparencyControlSpaceBackground, expected: "SpaceBackground"}, + {ctrl: 0x80, expected: "Unknown80"}, + } + + for _, tc := range tt { + result := tc.ctrl.String() + assert.Equal(t, tc.expected, result) + } +} + +func TestTransparencyControls(t *testing.T) { + controls := texture.TransparencyControls() + assert.Equal(t, 3, len(controls)) +} diff --git a/modding/hacked/ss1/doc.go b/modding/hacked/ss1/doc.go new file mode 100644 index 0000000..5c4323c --- /dev/null +++ b/modding/hacked/ss1/doc.go @@ -0,0 +1,5 @@ +/* +Package ss1 and its sub-packages contain System Shock 1 (1994) specifics. +These specifics are based on the ss-specs document of InkyBlackness (https://github.com/inkyblackness/ss-specs). +*/ +package ss1 diff --git a/modding/hacked/ss1/edit/AugmentedText.go b/modding/hacked/ss1/edit/AugmentedText.go new file mode 100644 index 0000000..698e669 --- /dev/null +++ b/modding/hacked/ss1/edit/AugmentedText.go @@ -0,0 +1,138 @@ +package edit + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/edit/media" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// AugmentedTextBlockSetter modifies resource blocks. +type AugmentedTextBlockSetter interface { + SetResourceBlock(lang resource.Language, id resource.ID, index int, data []byte) + SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte) + DelResource(lang resource.Language, id resource.ID) +} + +func augmentedTextAudioKeyFor(key resource.Key) resource.Key { + audioBase := KnownTexts().ByID(key.ID).audioBase + if audioBase == 0 { + return resource.KeyOf(0, resource.LangAny, 0) + } + + soundKey := key + soundKey.ID = audioBase.Plus(key.Index) + soundKey.Index = 0 + return soundKey +} + +// AugmentedTextService provides read/write functionality. +type AugmentedTextService struct { + textViewer media.TextViewerService + textSetter media.TextSetterService + audioViewer media.AudioViewerService + audioSetter media.AudioSetterService +} + +// NewAugmentedTextService returns a new instance based on given accessor. +func NewAugmentedTextService( + textViewer media.TextViewerService, textSetter media.TextSetterService, + audioViewer media.AudioViewerService, audioSetter media.AudioSetterService) AugmentedTextService { + return AugmentedTextService{ + textViewer: textViewer, + textSetter: textSetter, + audioViewer: audioViewer, + audioSetter: audioSetter, + } +} + +// WithAudio returns true if the identified resource is one with an audio component. +func (service AugmentedTextService) WithAudio(key resource.Key) bool { + return KnownTexts().ByID(key.ID).audioBase != 0 +} + +// Text returns the textual value of the identified text resource. +func (service AugmentedTextService) Text(key resource.Key) string { + return service.textViewer.Text(key) +} + +// SetText changes the textual value of a text resource. +func (service AugmentedTextService) SetText(setter AugmentedTextBlockSetter, key resource.Key, value string) { + service.textSetter.Set(setter, key, value) +} + +// RestoreTextFunc creates a snapshot of the current textual state and returns a function to restore it. +func (service AugmentedTextService) RestoreTextFunc(key resource.Key) func(setter AugmentedTextBlockSetter) { + oldText := service.textViewer.Text(key) + isModified := service.textViewer.Modified(key) + + return func(setter AugmentedTextBlockSetter) { + if isModified { + service.textSetter.Set(setter, key, oldText) + } else { + service.textSetter.Remove(setter, key) + } + } +} + +// Sound returns the audio value of the identified text resource. +// In case the text resource has no audio, an empty sound will be returned. +func (service AugmentedTextService) Sound(key resource.Key) audio.L8 { + if !service.WithAudio(key) { + return audio.L8{} + } + return service.audioViewer.Audio(augmentedTextAudioKeyFor(key)) +} + +// SetSound changes the sound of a text resource. +// Should the text resource have no audio component, this call does nothing. +func (service AugmentedTextService) SetSound(setter AugmentedTextBlockSetter, key resource.Key, sound audio.L8) { // nolint: interfacer + if service.WithAudio(key) { + service.audioSetter.Set(setter, augmentedTextAudioKeyFor(key), sound) + } +} + +// RestoreSoundFunc creates a snapshot of the current sound and returns a function to restore it. +// In case the text resource has no audio, a stub method will be returned. +func (service AugmentedTextService) RestoreSoundFunc(key resource.Key) func(setter AugmentedTextBlockSetter) { + if !service.WithAudio(key) { + return func(setter AugmentedTextBlockSetter) {} + } + + soundKey := augmentedTextAudioKeyFor(key) + isSoundModified := service.audioViewer.Modified(soundKey) + oldSound := service.audioViewer.Audio(soundKey) + + return func(setter AugmentedTextBlockSetter) { + if isSoundModified { + service.audioSetter.Set(setter, soundKey, oldSound) + } else { + service.audioSetter.Remove(setter, soundKey) + } + } +} + +// Clear sets the text to an empty string and sets an empty sound if audio is associated. +func (service AugmentedTextService) Clear(setter AugmentedTextBlockSetter, key resource.Key) { + service.textSetter.Clear(setter, key) + if service.WithAudio(key) { + service.audioSetter.Clear(setter, augmentedTextAudioKeyFor(key)) + } +} + +// Remove erases the text and audio from the resources. +func (service AugmentedTextService) Remove(setter AugmentedTextBlockSetter, key resource.Key) { + service.textSetter.Remove(setter, key) + if service.WithAudio(key) { + service.audioSetter.Remove(setter, augmentedTextAudioKeyFor(key)) + } +} + +// RestoreFunc creates a snapshot of all associated media components and returns a function to restore it. +func (service AugmentedTextService) RestoreFunc(key resource.Key) func(setter AugmentedTextBlockSetter) { + restoreText := service.RestoreTextFunc(key) + restoreSound := service.RestoreSoundFunc(key) + return func(setter AugmentedTextBlockSetter) { + restoreText(setter) + restoreSound(setter) + } +} diff --git a/modding/hacked/ss1/edit/EditableLevels.go b/modding/hacked/ss1/edit/EditableLevels.go new file mode 100644 index 0000000..e9bbbe2 --- /dev/null +++ b/modding/hacked/ss1/edit/EditableLevels.go @@ -0,0 +1,113 @@ +package edit + +import ( + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/content/archive/level/lvlids" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// EditableLevels is a list of levels that can be modified. +type EditableLevels struct { + registry cmd.Registry + mod *world.Mod + list [archive.MaxLevels]*level.Level +} + +// NewEditableLevels returns a new instance for given mod. +func NewEditableLevels(registry cmd.Registry, mod *world.Mod) *EditableLevels { + var levels EditableLevels + levels.registry = registry + levels.mod = mod + for i := 0; i < len(levels.list); i++ { + levels.list[i] = level.NewLevel(ids.LevelResourcesStart, i, mod) + } + return &levels +} + +// InvalidateResources informs all levels of changed resource identifier. +func (levels *EditableLevels) InvalidateResources(modifiedIDs []resource.ID) { + for _, lvl := range levels.list { + lvl.InvalidateResources(modifiedIDs) + } +} + +// Level returns the pointer to the given level. The function panics if the index is invalid. +func (levels *EditableLevels) Level(index int) *level.Level { + if !levels.IsLevelAvailable(index) { + panic("Invalid level index") + } + return levels.list[index] +} + +// IsLevelReadOnly returns true if the identified level cannot be modified. +func (levels *EditableLevels) IsLevelReadOnly(index int) bool { + isInMod := len(levels.mod.ModifiedBlocks(resource.LangAny, ids.LevelResourcesStart.Plus(lvlids.PerLevel*index+lvlids.FirstUsed))) > 0 + return !levels.IsLevelAvailable(index) || !isInMod +} + +// IsLevelAvailable returns true if the given index is a valid level. +func (levels *EditableLevels) IsLevelAvailable(index int) bool { + return (index >= 0) && (index < len(levels.list)) +} + +// IsObjectInUse returns true if the given object ID is in use in given level. +func (levels *EditableLevels) IsObjectInUse(levelIndex int, id level.ObjectID) bool { + if !levels.IsLevelAvailable(levelIndex) { + return false + } + lvl := levels.list[levelIndex] + obj := lvl.Object(id) + return (obj != nil) && (obj.InUse != 0) +} + +// IsTileOnMap returns true if the given tile position points to a valid tile in the given level. +func (levels *EditableLevels) IsTileOnMap(levelIndex int, pos level.TilePosition) bool { + if !levels.IsLevelAvailable(levelIndex) { + return false + } + lvl := levels.list[levelIndex] + return lvl.Tile(pos) != nil +} + +// CommitLevelChanges applies any level data change compared to the underlying mod. +func (levels *EditableLevels) CommitLevelChanges(index int) error { + lvl := levels.Level(index) + newDataSet := lvl.EncodeState() + var patches []world.BlockPatch + for id, newData := range &newDataSet { + if len(newData) > 0 { + resourceID := ids.LevelResourcesStart.Plus(lvlids.PerLevel*lvl.ID() + id) + patch, changed, err := levels.mod.CreateBlockPatch(resource.LangAny, resourceID, 0, newData) + if err != nil { + return err + } else if changed { + patches = append(patches, patch) + } + } + } + + return levels.registry.Register(cmd.Named("CommitLevelChanges"), + cmd.Forward(forwardTask(patches)), cmd.Reverse(reverseTask(patches))) +} + +func forwardTask(patches []world.BlockPatch) cmd.Task { + return func(modder world.Modder) error { + for _, patch := range patches { + modder.PatchResourceBlock(resource.LangAny, patch.ID, patch.BlockIndex, patch.BlockLength, patch.ForwardData) + } + return nil + } +} + +func reverseTask(patches []world.BlockPatch) cmd.Task { + return func(modder world.Modder) error { + for _, patch := range patches { + modder.PatchResourceBlock(resource.LangAny, patch.ID, patch.BlockIndex, patch.BlockLength, patch.ReverseData) + } + return nil + } +} diff --git a/modding/hacked/ss1/edit/GameObjectsService.go b/modding/hacked/ss1/edit/GameObjectsService.go new file mode 100644 index 0000000..8b73747 --- /dev/null +++ b/modding/hacked/ss1/edit/GameObjectsService.go @@ -0,0 +1,64 @@ +package edit + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/world" +) + +// GameObjectBitmapInfo contains indices about bitmaps for a particular game object. +type GameObjectBitmapInfo struct { + // Start is the index for the first object bitmap in ids.ObjectBitmaps. + Start int + // Count is the amount of bitmaps the object has in total. + Count int + // IconRecommendation is the offset from start for using icons. + IconRecommendation int +} + +// GameObjectsService provides information for game objects. +type GameObjectsService struct { + mod *world.Mod +} + +// NewGameObjectsService returns a new instance. +func NewGameObjectsService(mod *world.Mod) *GameObjectsService { + return &GameObjectsService{ + mod: mod, + } +} + +// AllProperties returns the currently know properties. +func (service *GameObjectsService) AllProperties() object.PropertiesTable { + return service.mod.ObjectProperties() +} + +// PropertiesFor returns the properties for the given triple. +func (service *GameObjectsService) PropertiesFor(triple object.Triple) (object.Properties, error) { + prop, err := service.AllProperties().ForObject(triple) + if err != nil { + return object.Properties{}, err + } + return *prop, err +} + +// BitmapInfo returns information on all bitmaps. +func (service *GameObjectsService) BitmapInfo() map[object.Triple]GameObjectBitmapInfo { + properties := service.AllProperties() + result := make(map[object.Triple]GameObjectBitmapInfo) + start := 0 + properties.Iterate(func(triple object.Triple, prop *object.Properties) bool { + numExtra := int(prop.Common.Bitmap3D.FrameNumber()) + info := GameObjectBitmapInfo{ + Start: start, + Count: 3 + numExtra, + IconRecommendation: 1, + } + if triple.Class != object.ClassTrap { + info.IconRecommendation += 2 + } + start += info.Count + result[triple] = info + return true + }) + return result +} diff --git a/modding/hacked/ss1/edit/GameStateService.go b/modding/hacked/ss1/edit/GameStateService.go new file mode 100644 index 0000000..68514ff --- /dev/null +++ b/modding/hacked/ss1/edit/GameStateService.go @@ -0,0 +1,272 @@ +package edit + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/citadel" +) + +// VariableBaseContextIdentifier identifies which context for variable naming should be used. +type VariableBaseContextIdentifier int + +// Context identifier are listed below. +const ( + VariableContextCitadel = VariableBaseContextIdentifier(0) + VariableContextEngine = VariableBaseContextIdentifier(1) +) + +// VariableContextIdentifiers returns a list of all available identifiers. +func VariableContextIdentifiers() []VariableBaseContextIdentifier { + return []VariableBaseContextIdentifier{VariableContextCitadel, VariableContextEngine} +} + +// GameStateSettings describe the game state details. +type GameStateSettings struct { + BaseContext int + + BooleanVariables []VariableSettings + IntegerVariables []VariableSettings +} + +// VariableSettings describe game variables. +type VariableSettings struct { + Index int + + ResetValue *int16 `json:",omitempty"` + Name string + Description string + Limits *struct { + Minimum int16 + Maximum int16 + } `json:",omitempty"` + ValueNames []struct { + Value int16 + Name string + } `json:",omitempty"` +} + +func variableSettingsFrom(variables archive.GameVariables) []VariableSettings { + settings := make([]VariableSettings, 0, len(variables)) + for varIndex, varInfo := range variables { + varSettings := VariableSettings{ + Index: varIndex, + ResetValue: varInfo.InitValue, + Name: varInfo.Name, + Description: varInfo.Description, + Limits: nil, + ValueNames: nil, + } + if varInfo.Limits != nil { + varSettings.Limits = &struct { + Minimum int16 + Maximum int16 + }{ + Minimum: varInfo.Limits.Minimum, + Maximum: varInfo.Limits.Maximum, + } + } + for valIndex, valName := range varInfo.ValueNames { + varSettings.ValueNames = append(varSettings.ValueNames, + struct { + Value int16 + Name string + }{ + Value: valIndex, + Name: valName, + }) + } + settings = append(settings, varSettings) + } + return settings +} + +func variablesFrom(list []VariableSettings) archive.GameVariables { + variables := make(archive.GameVariables) + for _, settings := range list { + varInfo := archive.GameVariableInfo{ + InitValue: settings.ResetValue, + Name: settings.Name, + Description: settings.Description, + } + if settings.Limits != nil { + varInfo.Limits = &archive.GameVariableLimits{ + Minimum: settings.Limits.Maximum, + Maximum: settings.Limits.Minimum, + } + } + if len(settings.ValueNames) > 0 { + varInfo.ValueNames = make(map[int16]string) + for _, valName := range settings.ValueNames { + varInfo.ValueNames[valName.Value] = valName.Name + } + } + variables[settings.Index] = varInfo + } + return variables +} + +// GameStateService handles all details on the global game state. +type GameStateService struct { + registry cmd.Registry + + currentContext VariableBaseContextIdentifier + + booleanVariables archive.GameVariables + integerVariables archive.GameVariables +} + +// NewGameStateService returns a new instance. +func NewGameStateService(registry cmd.Registry) *GameStateService { + return &GameStateService{ + registry: registry, + + currentContext: VariableContextCitadel, + + booleanVariables: make(archive.GameVariables), + integerVariables: make(archive.GameVariables), + } +} + +// CurrentSettings returns the snapshot of the project. +func (service GameStateService) CurrentSettings() GameStateSettings { + return GameStateSettings{ + BaseContext: int(service.currentContext), + BooleanVariables: variableSettingsFrom(service.booleanVariables), + IntegerVariables: variableSettingsFrom(service.integerVariables), + } +} + +// RestoreSettings loads the given snapshot. +func (service *GameStateService) RestoreSettings(settings GameStateSettings) { + service.currentContext = VariableBaseContextIdentifier(settings.BaseContext) + service.booleanVariables = variablesFrom(settings.BooleanVariables) + service.integerVariables = variablesFrom(settings.IntegerVariables) +} + +// VariableBaseContext returns the current context to use as basis for variables not specified by the project. +func (service GameStateService) VariableBaseContext() VariableBaseContextIdentifier { + return service.currentContext +} + +// SetVariableBaseContext sets the current context to use as basis for variables not specified by the project. +func (service *GameStateService) SetVariableBaseContext(identifier VariableBaseContextIdentifier) { + service.currentContext = identifier +} + +func (service GameStateService) baseInfoProvider() archive.GameVariableInfoProvider { + switch service.currentContext { + case VariableContextCitadel: + return citadel.MissionVariables{} + case VariableContextEngine: + return archive.EngineVariables{} + default: + panic(fmt.Sprintf("invalid identifier: %d", service.currentContext)) + } +} + +// DefaultAllVariables removes all project specific overrides. +func (service *GameStateService) DefaultAllVariables() error { + return service.registry.Register(cmd.Named("RemoveAllOverrides"), + cmd.Nested(func() error { + for i := 0; i < archive.BooleanVarCount; i++ { + err := service.DefaultBooleanVariable(i) + if err != nil { + return err + } + } + for i := 0; i < archive.IntegerVarCount; i++ { + err := service.DefaultIntegerVariable(i) + if err != nil { + return err + } + } + return nil + })) +} + +// IntegerVariableOverride returns true if project-specific details are stored for given variable index. +func (service GameStateService) IntegerVariableOverride(index int) bool { + _, perProject := service.integerVariables[index] + return perProject +} + +// IntegerVariable returns the variable info as per project settings for the given index. +func (service GameStateService) IntegerVariable(index int) archive.GameVariableInfo { + baseInfo := service.baseInfoProvider().IntegerVariable(index) + projInfo, perProject := service.integerVariables[index] + if perProject { + projInfo.Hardcoded = baseInfo.Hardcoded + return projInfo + } + return baseInfo +} + +// SetIntegerVariable sets the variable info for the given index in the project settings. +func (service *GameStateService) SetIntegerVariable(index int, info archive.GameVariableInfo) error { + return service.registry.Register(cmd.Named("SetIntegerVariable"), + cmd.Forward(setVariableTask(service.integerVariables, index, info)), + cmd.Reverse(restoreVariableTask(service.integerVariables, index))) +} + +// DefaultIntegerVariable clears the variable info for the given index in the project settings. +func (service *GameStateService) DefaultIntegerVariable(index int) error { + return service.registry.Register(cmd.Named("DefaultIntegerVariable"), + cmd.Forward(deleteVariableTask(service.integerVariables, index)), + cmd.Reverse(restoreVariableTask(service.integerVariables, index))) +} + +// BooleanVariableOverride returns true if project-specific details are stored for given variable index. +func (service GameStateService) BooleanVariableOverride(index int) bool { + _, perProject := service.booleanVariables[index] + return perProject +} + +// BooleanVariable returns the variable info as per project settings for the given index. +func (service GameStateService) BooleanVariable(index int) archive.GameVariableInfo { + baseInfo := service.baseInfoProvider().BooleanVariable(index) + projInfo, perProject := service.booleanVariables[index] + if perProject { + projInfo.Hardcoded = baseInfo.Hardcoded + return projInfo + } + return baseInfo +} + +// SetBooleanVariable sets the variable info for the given index in the project settings. +func (service *GameStateService) SetBooleanVariable(index int, info archive.GameVariableInfo) error { + return service.registry.Register(cmd.Named("SetBooleanVariable"), + cmd.Forward(setVariableTask(service.booleanVariables, index, info)), + cmd.Reverse(restoreVariableTask(service.booleanVariables, index))) +} + +// DefaultBooleanVariable clears the variable info for the given index in the project settings. +func (service *GameStateService) DefaultBooleanVariable(index int) error { + return service.registry.Register(cmd.Named("DefaultBooleanVariable"), + cmd.Forward(deleteVariableTask(service.booleanVariables, index)), + cmd.Reverse(restoreVariableTask(service.booleanVariables, index))) +} + +func setVariableTask(variables archive.GameVariables, index int, info archive.GameVariableInfo) cmd.Task { + return func(modder world.Modder) error { + variables[index] = info + return nil + } +} + +func deleteVariableTask(variables archive.GameVariables, index int) cmd.Task { + return func(modder world.Modder) error { + delete(variables, index) + return nil + } +} + +func restoreVariableTask(variables archive.GameVariables, index int) cmd.Task { + info, present := variables[index] + if present { + return setVariableTask(variables, index, info) + } + return deleteVariableTask(variables, index) +} diff --git a/modding/hacked/ss1/edit/KnownResources.go b/modding/hacked/ss1/edit/KnownResources.go new file mode 100644 index 0000000..cb532ec --- /dev/null +++ b/modding/hacked/ss1/edit/KnownResources.go @@ -0,0 +1,55 @@ +package edit + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// TextInfo describes one kind of text. +type TextInfo struct { + ID resource.ID + Title string + + audioBase resource.ID +} + +// TextInfoList is a set of TextInfo. +type TextInfoList []TextInfo + +// ByID returns a text info for the given identifier. +func (list TextInfoList) ByID(id resource.ID) TextInfo { + for _, info := range list { + if info.ID == id { + return info + } + } + return TextInfo{ + ID: id, + Title: fmt.Sprintf("??? %v", id), + } +} + +// Title returns the title property of the identified info. +func (list TextInfoList) Title(id resource.ID) string { + return list.ByID(id).Title +} + +var knownTexts = TextInfoList{ + {ID: ids.TrapMessageTexts, Title: "Trap Messages", audioBase: ids.TrapMessagesAudioStart}, + {ID: ids.WordTexts, Title: "Words"}, + {ID: ids.LogCategoryTexts, Title: "Log Categories"}, + {ID: ids.VariousMessageTexts, Title: "Various Messages"}, + {ID: ids.ScreenMessageTexts, Title: "Screen Messages"}, + {ID: ids.InfoNodeMessageTexts, Title: "Info Node Message Texts (8/5/6)"}, + {ID: ids.AccessCardNameTexts, Title: "Access Card Names"}, + {ID: ids.DataletMessageTexts, Title: "Datalet Messages (8/5/8)"}, + {ID: ids.PaperTextsStart, Title: "Papers"}, + {ID: ids.PanelNameTexts, Title: "Panel Names"}, +} + +// KnownTexts returns a set of known texts. +func KnownTexts() TextInfoList { + return knownTexts +} diff --git a/modding/hacked/ss1/edit/LevelEditorService.go b/modding/hacked/ss1/edit/LevelEditorService.go new file mode 100644 index 0000000..ff90037 --- /dev/null +++ b/modding/hacked/ss1/edit/LevelEditorService.go @@ -0,0 +1,257 @@ +package edit + +import ( + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/world" +) + +// LevelEditorService provides level editing functionality based on the currently selected level and content. +type LevelEditorService struct { + registry cmd.Registry + + gameObjects *GameObjectsService + levels *EditableLevels + levelSelection *LevelSelectionService + + newObjectTriple object.Triple +} + +// NewLevelEditorService returns a new instance. +func NewLevelEditorService(registry cmd.Registry, + gameObjects *GameObjectsService, + levels *EditableLevels, + levelSelection *LevelSelectionService) *LevelEditorService { + return &LevelEditorService{ + registry: registry, + gameObjects: gameObjects, + levels: levels, + levelSelection: levelSelection, + } +} + +// IsReadOnly returns true if the currently selected level cannot be modified. +func (service *LevelEditorService) IsReadOnly() bool { + return service.levels.IsLevelReadOnly(service.levelSelection.CurrentLevelID()) +} + +// Level returns the currently selected level. +func (service *LevelEditorService) Level() *level.Level { + return service.levels.Level(service.levelSelection.CurrentLevelID()) +} + +// Tiles returns the list of currently selected tiles of the current level. +func (service *LevelEditorService) Tiles() []*level.TileMapEntry { + lvl := service.Level() + positions := service.levelSelection.CurrentSelectedTiles() + tiles := make([]*level.TileMapEntry, len(positions)) + for i, pos := range positions { + tiles[i] = lvl.Tile(pos) + } + return tiles +} + +// HasSelectedTiles returns true if Tiles() returns at least one entry. +func (service *LevelEditorService) HasSelectedTiles() bool { + return len(service.levelSelection.CurrentSelectedTiles()) > 0 +} + +// ClearTiles resets the selected tiles. +func (service *LevelEditorService) ClearTiles() error { + return service.ChangeTiles(func(tile *level.TileMapEntry) { + index := tile.FirstObjectIndex + tile.Reset() + tile.FirstObjectIndex = index + }) +} + +// ChangeTiles performs a modification on all currently selected tiles and commits these changes to the repository. +func (service *LevelEditorService) ChangeTiles(modifier func(*level.TileMapEntry)) error { + positions := service.levelSelection.CurrentSelectedTiles() + if len(positions) == 0 { + return nil + } + lvl := service.Level() + for _, pos := range positions { + modifier(lvl.Tile(pos)) + } + levelID := lvl.ID() + return service.registry.Register(cmd.Named("ChangeTiles"), + cmd.Forward(service.levelSelection.SetCurrentLevelIDTask(levelID)), + cmd.Forward(service.setSelectedTilesTask(positions)), + cmd.Nested(func() error { return service.levels.CommitLevelChanges(levelID) }), + cmd.Reverse(service.setSelectedTilesTask(positions)), + cmd.Reverse(service.levelSelection.SetCurrentLevelIDTask(levelID)), + ) +} + +// Objects returns the list of currently selected objects of the current level. +func (service *LevelEditorService) Objects() []*level.ObjectMainEntry { + lvl := service.Level() + ids := service.levelSelection.CurrentSelectedObjects() + objects := make([]*level.ObjectMainEntry, len(ids)) + for i, id := range ids { + objects[i] = lvl.Object(id) + } + return objects +} + +// HasSelectedObjects returns true if a call to Objects() returns at least one item. +func (service *LevelEditorService) HasSelectedObjects() bool { + return len(service.levelSelection.CurrentSelectedObjects()) > 0 +} + +// NewObjectTriple returns the identifier of implicitly created objects. +func (service *LevelEditorService) NewObjectTriple() object.Triple { + return service.newObjectTriple +} + +// SetNewObjectTriple sets the identifier for implicitly created objects. +func (service *LevelEditorService) SetNewObjectTriple(value object.Triple) { + service.newObjectTriple = value +} + +// CreateNewObject adds a new object to the level and selects it. +func (service *LevelEditorService) CreateNewObject(triple object.Triple, modifier level.ObjectMainEntryModifier) error { + lvl := service.Level() + id, err := lvl.NewObject(triple.Class) + if err != nil { + return err + } + obj := lvl.Object(id) + obj.Subclass = triple.Subclass + obj.Type = triple.Type + service.resetHitpointsToDefault(obj) + modifier(obj) + service.placeObject(lvl, obj, service.atFloorLevelIn(lvl)) + lvl.UpdateObjectLocation(id) + return service.patchLevelObjects(lvl, service.levelSelection.CurrentSelectedObjects(), []level.ObjectID{id}) +} + +// PlaceObjectsOnFloor puts all selected objects to sit on the floor. +func (service *LevelEditorService) PlaceObjectsOnFloor() error { + lvl := service.Level() + atHeight := service.atFloorLevelIn(lvl) + return service.ChangeObjects(func(obj *level.ObjectMainEntry) { service.placeObject(lvl, obj, atHeight) }) +} + +// PlaceObjectsOnEyeLevel puts all selected objects to be at eye level (approximately). +func (service *LevelEditorService) PlaceObjectsOnEyeLevel() error { + lvl := service.Level() + atHeight := service.atEyeLevelIn(lvl) + return service.ChangeObjects(func(obj *level.ObjectMainEntry) { service.placeObject(lvl, obj, atHeight) }) +} + +// PlaceObjectsOnCeiling puts all selected objects to hang from the ceiling. +func (service *LevelEditorService) PlaceObjectsOnCeiling() error { + lvl := service.Level() + atHeight := service.atCeilingLevelIn(lvl) + return service.ChangeObjects(func(obj *level.ObjectMainEntry) { service.placeObject(lvl, obj, atHeight) }) +} + +// ChangeObjects modifies properties of selected objects. The modifier is called for each selected object. +func (service *LevelEditorService) ChangeObjects(modifier level.ObjectMainEntryModifier) error { + objectIDs := service.levelSelection.CurrentSelectedObjects() + if len(objectIDs) == 0 { + return nil + } + lvl := service.Level() + for _, id := range objectIDs { + obj := lvl.Object(id) + oldPosition := obj.TilePosition() + modifier(obj) + if oldPosition != obj.TilePosition() { + lvl.UpdateObjectLocation(id) + } + } + return service.patchLevelObjects(lvl, objectIDs, objectIDs) +} + +// DeleteObjects deletes all currently selected objects, clearing the selection afterwards. +func (service *LevelEditorService) DeleteObjects() error { + objectIDs := service.levelSelection.CurrentSelectedObjects() + if len(objectIDs) == 0 { + return nil + } + lvl := service.Level() + for _, id := range objectIDs { + lvl.DelObject(id) + } + return service.patchLevelObjects(lvl, objectIDs, nil) +} + +func (service *LevelEditorService) patchLevelObjects( + lvl *level.Level, + reverseObjectIDs []level.ObjectID, + forwardObjectIDs []level.ObjectID) error { + levelID := lvl.ID() + + return service.registry.Register(cmd.Named("PatchLevelObjects"), + cmd.Forward(service.levelSelection.SetCurrentLevelIDTask(levelID)), + cmd.Reverse(service.setSelectedObjectsTask(reverseObjectIDs)), + cmd.Nested(func() error { return service.levels.CommitLevelChanges(levelID) }), + cmd.Forward(service.setSelectedObjectsTask(forwardObjectIDs)), + cmd.Reverse(service.levelSelection.SetCurrentLevelIDTask(levelID)), + ) +} + +func (service *LevelEditorService) setSelectedTilesTask(positions []level.TilePosition) cmd.Task { + return func(world.Modder) error { + service.levelSelection.SetCurrentSelectedTiles(positions) + return nil + } +} + +func (service *LevelEditorService) setSelectedObjectsTask(ids []level.ObjectID) cmd.Task { + return func(world.Modder) error { + service.levelSelection.SetCurrentSelectedObjects(ids) + return nil + } +} + +type heightCalculatorFunc func(tile *level.TileMapEntry, pos level.FinePosition, objPivot float32) level.HeightUnit + +func (service *LevelEditorService) placeObject(lvl *level.Level, obj *level.ObjectMainEntry, atHeight heightCalculatorFunc) { + var objPivot float32 + prop, err := service.gameObjects.PropertiesFor(obj.Triple()) + if err == nil { + objPivot = object.Pivot(prop.Common) + } + tile := lvl.Tile(obj.TilePosition()) + if tile != nil { + obj.Z = atHeight(tile, obj.FinePosition(), objPivot) + } +} + +func (service *LevelEditorService) atFloorLevelIn(lvl *level.Level) heightCalculatorFunc { + _, _, height := lvl.Size() + return func(tile *level.TileMapEntry, pos level.FinePosition, objPivot float32) level.HeightUnit { + floorHeight := tile.FloorTileHeightAt(pos, height) + return height.ValueToObjectHeight(floorHeight + objPivot) + } +} + +func (service *LevelEditorService) atEyeLevelIn(lvl *level.Level) heightCalculatorFunc { + _, _, height := lvl.Size() + return func(tile *level.TileMapEntry, pos level.FinePosition, objPivot float32) level.HeightUnit { + floorHeight := tile.FloorTileHeightAt(pos, height) + return height.ValueToObjectHeight(floorHeight + 0.75 - objPivot) + } +} + +func (service *LevelEditorService) atCeilingLevelIn(lvl *level.Level) heightCalculatorFunc { + _, _, height := lvl.Size() + return func(tile *level.TileMapEntry, pos level.FinePosition, objPivot float32) level.HeightUnit { + floorHeight := tile.CeilingTileHeightAt(pos, height) + return height.ValueToObjectHeight(floorHeight - objPivot) + } +} + +func (service *LevelEditorService) resetHitpointsToDefault(obj *level.ObjectMainEntry) { + prop, err := service.gameObjects.PropertiesFor(obj.Triple()) + if err != nil { + return + } + obj.Hitpoints = prop.Common.Hitpoints +} diff --git a/modding/hacked/ss1/edit/LevelSelectionService.go b/modding/hacked/ss1/edit/LevelSelectionService.go new file mode 100644 index 0000000..5593c8a --- /dev/null +++ b/modding/hacked/ss1/edit/LevelSelectionService.go @@ -0,0 +1,231 @@ +package edit + +import ( + "sort" + + "github.com/inkyblackness/hacked/ss1/content/archive/level" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/world" +) + +type levelSelection struct { + tiles map[level.TilePosition]struct{} + objects map[level.ObjectID]struct{} +} + +func (selection levelSelection) tileList() []level.TilePosition { + list := make([]level.TilePosition, 0, len(selection.tiles)) + for pos := range selection.tiles { + list = append(list, pos) + } + sort.Slice(list, func(a, b int) bool { + posA := list[a] + posB := list[b] + return (posA.Y < posB.Y) || ((posA.Y == posB.Y) && (posA.X < posB.X)) + }) + return list +} + +func (selection levelSelection) objectList() []level.ObjectID { + list := make([]level.ObjectID, 0, len(selection.objects)) + for id := range selection.objects { + list = append(list, id) + } + sort.Slice(list, func(a, b int) bool { return list[a] < list[b] }) + return list +} + +// LevelInfoProvider returns details on level content. +type LevelInfoProvider interface { + IsLevelAvailable(index int) bool + IsObjectInUse(levelIndex int, id level.ObjectID) bool + IsTileOnMap(levelIndex int, pos level.TilePosition) bool +} + +// LevelSelectionService keeps track of selections related to levels. +type LevelSelectionService struct { + provider LevelInfoProvider + + currentLevel int + + levels map[int]*levelSelection +} + +// NewLevelSelectionService returns a new instance. +func NewLevelSelectionService(provider LevelInfoProvider) *LevelSelectionService { + return &LevelSelectionService{ + provider: provider, + currentLevel: 0, + levels: make(map[int]*levelSelection), + } +} + +// CurrentLevelID returns the identifier for the currently selected level. +func (service *LevelSelectionService) CurrentLevelID() int { + if !service.provider.IsLevelAvailable(service.currentLevel) { + return 0 + } + return service.currentLevel +} + +// SetCurrentLevelID sets the currently selected level. +func (service *LevelSelectionService) SetCurrentLevelID(index int) { + service.currentLevel = index +} + +// SetCurrentLevelIDTask returns a command task that will set the current level. +func (service *LevelSelectionService) SetCurrentLevelIDTask(index int) cmd.Task { + return func(modder world.Modder) error { + service.SetCurrentLevelID(index) + return nil + } +} + +func (service *LevelSelectionService) currentSelection() levelSelection { + selection, ok := service.levels[service.currentLevel] + if !ok { + return levelSelection{} + } + return *service.cleanSelection(service.currentLevel, selection) +} + +func (service *LevelSelectionService) modifiableSelection() *levelSelection { + selection, ok := service.levels[service.currentLevel] + if !ok { + selection = &levelSelection{ + tiles: make(map[level.TilePosition]struct{}), + objects: make(map[level.ObjectID]struct{}), + } + service.levels[service.currentLevel] = selection + } + return service.cleanSelection(service.currentLevel, selection) +} + +func (service *LevelSelectionService) cleanSelection(levelIndex int, selection *levelSelection) *levelSelection { + return service.cleanSelectionTiles(levelIndex, service.cleanSelectionObjects(levelIndex, selection)) +} + +func (service *LevelSelectionService) cleanSelectionTiles(levelIndex int, selection *levelSelection) *levelSelection { + var toDelete []level.TilePosition + for pos := range selection.tiles { + if !service.provider.IsTileOnMap(levelIndex, pos) { + toDelete = append(toDelete, pos) + } + } + for _, pos := range toDelete { + delete(selection.tiles, pos) + } + return selection +} + +func (service *LevelSelectionService) cleanSelectionObjects(levelIndex int, selection *levelSelection) *levelSelection { + var toDelete []level.ObjectID + for id := range selection.objects { + if !service.provider.IsObjectInUse(levelIndex, id) { + toDelete = append(toDelete, id) + } + } + for _, id := range toDelete { + delete(selection.objects, id) + } + return selection +} + +// IsTileSelected returns true if the given tile is currently selected. +func (service *LevelSelectionService) IsTileSelected(pos level.TilePosition) bool { + _, selected := service.currentSelection().tiles[pos] + return selected +} + +// CurrentSelectedTiles returns the list of currently selected tiles in the current level. +func (service *LevelSelectionService) CurrentSelectedTiles() []level.TilePosition { + return service.currentSelection().tileList() +} + +// SetCurrentSelectedTiles sets the list of currently selected tiles in the current level. +func (service *LevelSelectionService) SetCurrentSelectedTiles(list []level.TilePosition) { + selection := service.modifiableSelection() + for pos := range selection.tiles { + delete(selection.tiles, pos) + } + for _, pos := range list { + selection.tiles[pos] = struct{}{} + } +} + +// AddCurrentSelectedTiles adds the given tile IDs to the list of currently selected in the current level. +func (service *LevelSelectionService) AddCurrentSelectedTiles(list []level.TilePosition) { + selection := service.modifiableSelection() + for _, pos := range list { + selection.tiles[pos] = struct{}{} + } +} + +// RemoveCurrentSelectedTiles removes the given tile IDs from the list of currently selected in the current level. +func (service *LevelSelectionService) RemoveCurrentSelectedTiles(list []level.TilePosition) { + selection := service.modifiableSelection() + for _, pos := range list { + delete(selection.tiles, pos) + } +} + +// ToggleTileSelection toggles the selection of the given tile IDs in the current level. +func (service *LevelSelectionService) ToggleTileSelection(list []level.TilePosition) { + selection := service.modifiableSelection() + for _, pos := range list { + if _, selected := selection.tiles[pos]; selected { + delete(selection.tiles, pos) + } else { + selection.tiles[pos] = struct{}{} + } + } +} + +// NumberOfSelectedObjects returns the number of currently selected objects in the current level. +func (service *LevelSelectionService) NumberOfSelectedObjects() int { + return len(service.currentSelection().objects) +} + +// CurrentSelectedObjects returns the list of currently selected objects in the current level. +func (service *LevelSelectionService) CurrentSelectedObjects() []level.ObjectID { + return service.currentSelection().objectList() +} + +// SetCurrentSelectedObjects sets the list of currently selected objects in the current level. +func (service *LevelSelectionService) SetCurrentSelectedObjects(ids []level.ObjectID) { + selection := service.modifiableSelection() + for id := range selection.objects { + delete(selection.objects, id) + } + for _, id := range ids { + selection.objects[id] = struct{}{} + } +} + +// AddCurrentSelectedObjects adds the given object IDs to the list of currently selected in the current level. +func (service *LevelSelectionService) AddCurrentSelectedObjects(ids []level.ObjectID) { + selection := service.modifiableSelection() + for _, id := range ids { + selection.objects[id] = struct{}{} + } +} + +// RemoveCurrentSelectedObjects removes the given object IDs from the list of currently selected in the current level. +func (service *LevelSelectionService) RemoveCurrentSelectedObjects(ids []level.ObjectID) { + selection := service.modifiableSelection() + for _, id := range ids { + delete(selection.objects, id) + } +} + +// ToggleObjectSelection toggles the selection of the given object IDs in the current level. +func (service *LevelSelectionService) ToggleObjectSelection(ids []level.ObjectID) { + selection := service.modifiableSelection() + for _, id := range ids { + if _, selected := selection.objects[id]; selected { + delete(selection.objects, id) + } else { + selection.objects[id] = struct{}{} + } + } +} diff --git a/modding/hacked/ss1/edit/MovieService.go b/modding/hacked/ss1/edit/MovieService.go new file mode 100644 index 0000000..36ba818 --- /dev/null +++ b/modding/hacked/ss1/edit/MovieService.go @@ -0,0 +1,155 @@ +package edit + +import ( + "time" + + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/edit/media" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// MovieService provides read/write functionality. +type MovieService struct { + cp text.Codepage + + movieViewer media.MovieViewerService + movieSetter media.MovieSetterService +} + +// NewMovieService returns a new instance based on given accessor. +func NewMovieService(codepage text.Codepage, + movieViewer media.MovieViewerService, movieSetter media.MovieSetterService) MovieService { + return MovieService{ + cp: codepage, + + movieViewer: movieViewer, + movieSetter: movieSetter, + } +} + +// RestoreFunc creates a snapshot of the current movie and returns a function to restore it. +func (service MovieService) RestoreFunc(key resource.Key) func(setter media.MovieBlockSetter) { + oldContainer, _ := service.movieViewer.Container(key) + isModified := service.movieViewer.Modified(key) + + return func(setter media.MovieBlockSetter) { + if isModified { + service.movieSetter.Set(setter, key, oldContainer) + } else { + service.movieSetter.Remove(setter, key) + } + } +} + +// Remove erases the movie from the resources. +func (service MovieService) Remove(setter media.MovieBlockSetter, key resource.Key) { + service.movieSetter.Remove(setter, key) +} + +// SizeWarning returns true if the given movie is larger than the underlying archive would support. +func (service MovieService) SizeWarning(key resource.Key) bool { + return service.movieViewer.SizeWarning(key) +} + +// Video returns the video component of identified movie. +func (service MovieService) Video(key resource.Key) []movie.Scene { + return service.movieViewer.Video(key) +} + +// MoveSceneEarlier moves the given scene one step earlier. +func (service MovieService) MoveSceneEarlier(setter media.MovieBlockSetter, key resource.Key, scene int) { + service.swapScenes(setter, key, scene, scene-1) +} + +// MoveSceneLater moves the given scene one step later. +func (service MovieService) MoveSceneLater(setter media.MovieBlockSetter, key resource.Key, scene int) { + service.swapScenes(setter, key, scene, scene+1) +} + +func (service MovieService) swapScenes(setter media.MovieBlockSetter, key resource.Key, sceneA, sceneB int) { + baseContainer := service.getBaseContainer(key) + if (sceneA < 0) || (sceneA >= (len(baseContainer.Video.Scenes))) { + return + } + if (sceneB < 0) || (sceneB >= (len(baseContainer.Video.Scenes))) { + return + } + scenes := make([]movie.HighResScene, len(baseContainer.Video.Scenes)) + copy(scenes, baseContainer.Video.Scenes) + scenes[sceneA] = baseContainer.Video.Scenes[sceneB] + scenes[sceneB] = baseContainer.Video.Scenes[sceneA] + baseContainer.Video.Scenes = scenes + service.movieSetter.Set(setter, key, baseContainer) +} + +// AddScene adds the given scene at the end of the movie. +func (service MovieService) AddScene(setter media.MovieBlockSetter, key resource.Key, scene movie.HighResScene) { + baseContainer := service.getBaseContainer(key) + baseContainer.Video.Scenes = append(baseContainer.Video.Scenes, scene) + service.movieSetter.Set(setter, key, baseContainer) +} + +// RemoveScene cuts out the given scene from the movie. +func (service MovieService) RemoveScene(setter media.MovieBlockSetter, key resource.Key, scene int) { + baseContainer := service.getBaseContainer(key) + if (scene < 0) || (scene >= len(baseContainer.Video.Scenes)) { + return + } + scenes := make([]movie.HighResScene, len(baseContainer.Video.Scenes)-1) + copy(scenes[0:scene], baseContainer.Video.Scenes[0:scene]) + copy(scenes[scene:], baseContainer.Video.Scenes[scene+1:]) + baseContainer.Video.Scenes = scenes + service.movieSetter.Set(setter, key, baseContainer) +} + +// SetSceneFramesDisplayTime sets the display time for each frame. +func (service MovieService) SetSceneFramesDisplayTime(setter media.MovieBlockSetter, key resource.Key, + scene int, displayTime time.Duration) { + baseContainer := service.getBaseContainer(key) + if (scene < 0) || (scene >= len(baseContainer.Video.Scenes)) { + return + } + baseContainer.Video.Scenes[scene] = baseContainer.Video.Scenes[scene].WithFrameDisplayTime(displayTime) + service.movieSetter.Set(setter, key, baseContainer) +} + +// Audio returns the audio component of identified movie. +func (service MovieService) Audio(key resource.Key) audio.L8 { + return service.movieViewer.Audio(key) +} + +// SetAudio sets the audio component of identified movie. +func (service MovieService) SetAudio(setter media.MovieBlockSetter, key resource.Key, soundData audio.L8) { + baseContainer := service.getBaseContainer(key) + baseContainer.Audio.Sound = soundData + service.movieSetter.Set(setter, key, baseContainer) +} + +// Subtitles returns the subtitles associated with the given key. +func (service MovieService) Subtitles(key resource.Key, language resource.Language) movie.SubtitleList { + return service.movieViewer.Subtitles(key, language) +} + +// SetSubtitles sets the subtitles of identified movie in given language. +func (service MovieService) SetSubtitles(setter media.MovieBlockSetter, key resource.Key, + language resource.Language, subtitles movie.SubtitleList) { + baseContainer := service.getBaseContainer(key) + baseContainer.Subtitles.PerLanguage[language] = subtitles + service.movieSetter.Set(setter, key, baseContainer) +} + +func (service MovieService) getBaseContainer(key resource.Key) movie.Container { + container, err := service.movieViewer.Container(key) + if err != nil { + container = movie.Container{ + Audio: movie.Audio{Sound: audio.L8{SampleRate: 22050}}, + Video: movie.Video{ + Width: movie.HighResDefaultWidth, + Height: movie.HighResDefaultHeight, + }, + } + } + return container +} diff --git a/modding/hacked/ss1/edit/ProjectService.go b/modding/hacked/ss1/edit/ProjectService.go new file mode 100644 index 0000000..4c536fb --- /dev/null +++ b/modding/hacked/ss1/edit/ProjectService.go @@ -0,0 +1,394 @@ +package edit + +import ( + "bytes" + "os" + "path/filepath" + "time" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres" + "github.com/inkyblackness/hacked/ss1/serial" + "github.com/inkyblackness/hacked/ss1/world" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +const ( + errNoResourcesFound ss1.StringError = "no resources found" + errNoStorageLocationSet ss1.StringError = "no storage location set" +) + +// ProjectSettings describe the properties of a project. +type ProjectSettings struct { + ModFiles []string + Manifest []ManifestEntrySettings +} + +// ManifestEntrySettings describe the properties of one manifest entry in a project. +type ManifestEntrySettings struct { + Origin []string +} + +const autosaveTimeoutSec = 5 + +// SaveStatus describes the current change state. +type SaveStatus struct { + mod *world.Mod + // FilesModified is the count of how many files are affected by the current pending change. + FilesModified int + // SavePending is set if the mod has a storage location and changes are to be saved. + SavePending bool + // SaveIn is the duration after which an auto-save should happen. + SaveIn time.Duration +} + +// ConfirmPendingSave marks that the recent auto-save status has been acknowledged and should no longer be notified. +// A new change will re-start the auto-save timer. +func (status SaveStatus) ConfirmPendingSave() { + if status.mod == nil { + return + } + status.mod.ResetLastChangeTime() +} + +// ProjectService handles the overall information about the active project. +type ProjectService struct { + commander cmd.Registry + + mod *world.Mod + modPath string + + stateFilename string +} + +// NewProjectService returns a new instance of a service for given mod. +func NewProjectService(commander cmd.Registry, mod *world.Mod) *ProjectService { + return &ProjectService{ + commander: commander, + mod: mod, + } +} + +// SaveStatus returns the current pending save information. +func (service ProjectService) SaveStatus() SaveStatus { + var status SaveStatus + status.mod = service.mod + status.FilesModified = len(service.mod.ModifiedFilenames()) + if status.FilesModified > 0 { + lastChangeTime := service.mod.LastChangeTime() + + if service.ModHasStorageLocation() && !lastChangeTime.IsZero() { + status.SavePending = true + saveAt := lastChangeTime.Add(time.Duration(autosaveTimeoutSec) * time.Second) + status.SaveIn = time.Until(saveAt) + if status.SaveIn <= 0 { + status.SaveIn = 0 + } + } + } + return status +} + +// CurrentStateFilename returns the name for the state of the project. +func (service ProjectService) CurrentStateFilename() string { + return service.stateFilename +} + +// SetCurrentStateFilename updates the current filename. +func (service *ProjectService) SetCurrentStateFilename(value string) { + service.stateFilename = value +} + +// CurrentSettings returns the snapshot of the project. +func (service ProjectService) CurrentSettings() ProjectSettings { + manifest := service.mod.World() + settings := ProjectSettings{Manifest: make([]ManifestEntrySettings, manifest.EntryCount())} + for i := 0; i < manifest.EntryCount(); i++ { + entry, _ := manifest.Entry(i) + settings.Manifest[i].Origin = entry.Origin + } + + settings.ModFiles = service.relativeToSettings(service.mod.AllAbsoluteFilenames(service.modPath)...) + + return settings +} + +func (service *ProjectService) relativeToSettings(filenames ...string) []string { + loc := world.FileLocationFrom(service.stateFilename) + relatives := make([]string, 0, len(filenames)) + for _, filename := range filenames { + relatives = append(relatives, world.FileLocationFrom(filename).NestedRelativeTo(loc.DirPath)) + } + return relatives +} + +func (service *ProjectService) absoluteFromSettings(filenames ...string) []string { + loc := world.FileLocationFrom(service.stateFilename) + absolutes := make([]string, 0, len(filenames)) + for _, filename := range filenames { + absolutes = append(absolutes, world.FileLocationFrom(filename).AbsolutePathFrom(loc.DirPath)) + } + return absolutes +} + +// RestoreProject sets internal data based on the given settings. +func (service *ProjectService) RestoreProject(settings ProjectSettings, stateFilename string) { + service.ResetProject() + + service.stateFilename = stateFilename + + manifest := service.mod.World() + for _, entrySettings := range settings.Manifest { + entry, err := world.NewManifestEntryFrom(entrySettings.Origin) + if err != nil { + continue + } + err = manifest.InsertEntry(manifest.EntryCount(), entry) + if err != nil { + continue + } + } + + _ = service.TryLoadModFrom(service.absoluteFromSettings(settings.ModFiles...)) +} + +// ResetProject clears the project and returns it to initial state. +func (service *ProjectService) ResetProject() { + service.setActiveMod("", nil, nil, nil) + service.mod.World().Reset() + service.stateFilename = "" +} + +// AddManifestEntry attempts to insert the given manifest entry at given index. +func (service *ProjectService) AddManifestEntry(at int, entry *world.ManifestEntry) error { + return service.commander.Register( + cmd.Named("AddManifestEntry"), + cmd.Forward(func(modder world.Modder) error { + return service.mod.World().InsertEntry(at, entry) + }), + cmd.Reverse(func(modder world.Modder) error { + return service.mod.World().RemoveEntry(at) + }), + ) +} + +// RemoveManifestEntry attempts to remove the manifest entry at given index. +func (service *ProjectService) RemoveManifestEntry(at int) error { + manifest := service.mod.World() + entry, err := manifest.Entry(at) + if err != nil { + return err + } + return service.commander.Register( + cmd.Named("RemoveManifestEntry"), + cmd.Forward(func(modder world.Modder) error { + return manifest.RemoveEntry(at) + }), + cmd.Reverse(func(modder world.Modder) error { + return manifest.InsertEntry(at, entry) + }), + ) +} + +// MoveManifestEntry attempts to remove the manifest entry at given from index and re-insert it at given to index. +func (service *ProjectService) MoveManifestEntry(to, from int) error { + return service.commander.Register( + cmd.Named("MoveManifestEntry"), + cmd.Forward(func(modder world.Modder) error { + return service.mod.World().MoveEntry(to, from) + }), + cmd.Reverse(func(modder world.Modder) error { + return service.mod.World().MoveEntry(from, to) + }), + ) +} + +// Mod returns the currently active mod in the project. +func (service ProjectService) Mod() *world.Mod { + return service.mod +} + +// NewMod resets the mod to a new state. +func (service *ProjectService) NewMod() { + service.setActiveMod("", nil, nil, nil) +} + +// TryLoadModFrom attempts to set the active mod from given filenames. +func (service *ProjectService) TryLoadModFrom(names []string) error { + loaded := world.LoadFiles(false, names) + + resourcesToTake := loaded.Resources + isSavegame := false + if (len(resourcesToTake) == 0) && (len(loaded.Savegames) == 1) { + resourcesToTake = loaded.Savegames + isSavegame = true + } + if len(resourcesToTake) == 0 { + return errNoResourcesFound + } + var locs []*world.LocalizedResources + modPath := "" + + for location := range resourcesToTake { + if (len(modPath) == 0) || (len(location.DirPath) < len(modPath)) { + modPath = location.DirPath + } + } + + for location, viewer := range resourcesToTake { + lang := ids.LocalizeFilename(location.Name) + template := location.Name + if isSavegame { + template = string(ids.Archive) + } + loc := &world.LocalizedResources{ + File: location, + Template: template, + Language: lang, + } + for _, id := range viewer.IDs() { + view, err := viewer.View(id) + if err != nil { + // optimistic ignore + continue + } + _ = loc.Store.Put(id, view) + } + locs = append(locs, loc) + } + + service.setActiveMod(modPath, locs, loaded.ObjectProperties, loaded.TextureProperties) + return nil +} + +func (service *ProjectService) setActiveMod(modPath string, resources []*world.LocalizedResources, + objectProperties object.PropertiesTable, textureProperties texture.PropertiesList) { + service.setModPath(modPath) + service.mod.Reset(resources, objectProperties, textureProperties) + // fix list resources for any "old" mod. + service.mod.FixListResources() +} + +// ModifyModWith runs a function with the intent to alter the current mod. +func (service *ProjectService) ModifyModWith(modifier func(world.Modder) error) (err error) { + service.mod.Modify(func(modder world.Modder) { + err = modifier(modder) + }) + return +} + +// ModHasStorageLocation returns whether the mod has a place to be stored. +func (service ProjectService) ModHasStorageLocation() bool { + return len(service.modPath) > 0 +} + +// ModPath returns the base path of the mod in the project. +func (service ProjectService) ModPath() string { + return service.modPath +} + +func (service *ProjectService) setModPath(value string) { + service.modPath = value +} + +// SaveMod will store the currently active mod in its current path. +func (service *ProjectService) SaveMod() error { + if !service.ModHasStorageLocation() { + return errNoStorageLocationSet + } + return service.SaveModUnder(service.modPath) +} + +// SaveModUnder will store the currently active mod in the given path. +func (service *ProjectService) SaveModUnder(modPath string) error { + service.mod.FixListResources() + err := service.saveModResourcesTo(modPath) + if err != nil { + return err + } + service.setModPath(modPath) + service.mod.MarkSave() + return nil +} + +func (service *ProjectService) saveModResourcesTo(modPath string) error { + localized := service.mod.ModifiedResources() + filenamesToSave := service.mod.ModifiedFilenames() + + shallBeSaved := func(filename string) bool { + for _, toSave := range filenamesToSave { + if toSave == filename { + return true + } + } + return false + } + + for _, loc := range localized { + if shallBeSaved(loc.File.Name) { + err := saveResourcesTo(loc.Store, loc.File.AbsolutePathFrom(modPath)) + if err != nil { + return err + } + } + } + + if shallBeSaved(world.TexturePropertiesFilename) { + err := saveTexturePropertiesTo(service.mod.TextureProperties(), filepath.Join(modPath, world.TexturePropertiesFilename)) + if err != nil { + return err + } + } + if shallBeSaved(world.ObjectPropertiesFilename) { + err := saveObjectPropertiesTo(service.mod.ObjectProperties(), filepath.Join(modPath, world.ObjectPropertiesFilename)) + if err != nil { + return err + } + } + + return nil +} + +func saveResourcesTo(viewer resource.Viewer, absFilename string) error { + file, err := os.Create(absFilename) + if err != nil { + return err + } + defer func() { + _ = file.Close() // nolint: gas + }() + err = lgres.Write(file, viewer) + return err +} + +func saveTexturePropertiesTo(list texture.PropertiesList, absFilename string) error { + return saveCodableTo(list, absFilename) +} + +func saveObjectPropertiesTo(list object.PropertiesTable, absFilename string) error { + return saveCodableTo(list, absFilename) +} + +func saveCodableTo(codable serial.Codable, absFilename string) error { + buffer := bytes.NewBuffer(nil) + encoder := serial.NewEncoder(buffer) + codable.Code(encoder) + err := encoder.FirstError() + if err != nil { + return err + } + + file, err := os.Create(absFilename) + if err != nil { + return err + } + defer func() { + _ = file.Close() // nolint: gas + }() + _, err = file.Write(buffer.Bytes()) + return err +} diff --git a/modding/hacked/ss1/edit/SoundEffectService.go b/modding/hacked/ss1/edit/SoundEffectService.go new file mode 100644 index 0000000..5fe0d4e --- /dev/null +++ b/modding/hacked/ss1/edit/SoundEffectService.go @@ -0,0 +1,61 @@ +package edit + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/edit/media" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// SoundEffectService provides read/write functionality. +type SoundEffectService struct { + viewer media.SoundEffectViewerService + setter media.SoundEffectSetterService +} + +// NewSoundEffectService returns a new instance based on given accessor. +func NewSoundEffectService( + viewer media.SoundEffectViewerService, setter media.SoundEffectSetterService) SoundEffectService { + return SoundEffectService{ + viewer: viewer, + setter: setter, + } +} + +// RestoreFunc creates a snapshot of the current sound and returns a function to restore it. +func (service SoundEffectService) RestoreFunc(key resource.Key) func(setter media.SoundEffectBlockSetter) { + oldAudio := service.viewer.Audio(key) + isModified := service.viewer.Modified(key) + + return func(setter media.SoundEffectBlockSetter) { + if isModified { + service.setter.Set(setter, key, oldAudio) + } else { + service.setter.Remove(setter, key) + } + } +} + +// Modified returns true if the identified sound resource is marked as modified. +func (service SoundEffectService) Modified(key resource.Key) bool { + return service.viewer.Modified(key) +} + +// Remove erases the sound from the resources. +func (service SoundEffectService) Remove(setter media.SoundEffectBlockSetter, key resource.Key) { + service.setter.Remove(setter, key) +} + +// Clear resets the identified audio resource to a silent one-sample audio. +func (service SoundEffectService) Clear(setter media.SoundEffectBlockSetter, key resource.Key) { + service.setter.Clear(setter, key) +} + +// Audio returns the audio component of identified sound effect. +func (service SoundEffectService) Audio(key resource.Key) audio.L8 { + return service.viewer.Audio(key) +} + +// SetAudio sets the audio component of identified sound effect. +func (service SoundEffectService) SetAudio(setter media.SoundEffectBlockSetter, key resource.Key, data audio.L8) { + service.setter.Set(setter, key, data) +} diff --git a/modding/hacked/ss1/edit/doc.go b/modding/hacked/ss1/edit/doc.go new file mode 100644 index 0000000..eb53660 --- /dev/null +++ b/modding/hacked/ss1/edit/doc.go @@ -0,0 +1,3 @@ +// Package edit contains logic for creating and updating game content. +// The services in this package modify resources directly. +package edit diff --git a/modding/hacked/ss1/edit/media/AudioSetterService.go b/modding/hacked/ss1/edit/media/AudioSetterService.go new file mode 100644 index 0000000..3a1d5de --- /dev/null +++ b/modding/hacked/ss1/edit/media/AudioSetterService.go @@ -0,0 +1,39 @@ +package media + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// AudioBlockSetter modifies storage of raw resource data. +type AudioBlockSetter interface { + SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte) + DelResource(lang resource.Language, id resource.ID) +} + +// AudioSetterService provides methods to change audio resources. +type AudioSetterService struct{} + +// NewAudioSetterService returns a new instance. +func NewAudioSetterService() AudioSetterService { + return AudioSetterService{} +} + +// Remove deletes any audio resource for given key. +func (service AudioSetterService) Remove(setter AudioBlockSetter, key resource.Key) { + setter.DelResource(key.Lang, key.ID) +} + +// Clear resets the identified audio resource to a silent one-sample audio. +func (service AudioSetterService) Clear(setter AudioBlockSetter, key resource.Key) { + silence := audio.L8{SampleRate: 22050, Samples: []byte{0x80}} + service.Set(setter, key, silence) +} + +// Set stores the given sound as the identified resource. +func (service AudioSetterService) Set(setter AudioBlockSetter, key resource.Key, sound audio.L8) { + movieData := movie.ContainSoundData(sound) + blockData := [][]byte{movieData} + setter.SetResourceBlocks(key.Lang, key.ID, blockData) +} diff --git a/modding/hacked/ss1/edit/media/AudioViewerService.go b/modding/hacked/ss1/edit/media/AudioViewerService.go new file mode 100644 index 0000000..7a1eabc --- /dev/null +++ b/modding/hacked/ss1/edit/media/AudioViewerService.go @@ -0,0 +1,38 @@ +package media + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// AudioBlockGetter provides raw data of blocks. +type AudioBlockGetter interface { + ModifiedBlock(lang resource.Language, id resource.ID, index int) []byte +} + +// AudioViewerService provides read-only access to audio resources. +type AudioViewerService struct { + movieCache *movie.Cache + getter AudioBlockGetter +} + +// NewAudioViewerService returns a new instance. +func NewAudioViewerService(movieCache *movie.Cache, getter AudioBlockGetter) AudioViewerService { + return AudioViewerService{ + movieCache: movieCache, + getter: getter, + } +} + +// Audio returns the sound data associated with the given key. +func (service AudioViewerService) Audio(key resource.Key) audio.L8 { + sound, _ := service.movieCache.Audio(key) + return sound +} + +// Modified returns true if the identified audio resource is marked as modified. +func (service AudioViewerService) Modified(key resource.Key) bool { + data := service.getter.ModifiedBlock(key.Lang, key.ID, 0) + return len(data) > 0 +} diff --git a/modding/hacked/ss1/edit/media/MovieSetterService.go b/modding/hacked/ss1/edit/media/MovieSetterService.go new file mode 100644 index 0000000..48b2812 --- /dev/null +++ b/modding/hacked/ss1/edit/media/MovieSetterService.go @@ -0,0 +1,37 @@ +package media + +import ( + "bytes" + + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// MovieBlockSetter modifies storage of raw resource data. +type MovieBlockSetter interface { + SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte) + DelResource(lang resource.Language, id resource.ID) +} + +// MovieSetterService can be used to set movie data. +type MovieSetterService struct { + cp text.Codepage +} + +// NewMovieSetterService returns a new instance. +func NewMovieSetterService(cp text.Codepage) MovieSetterService { + return MovieSetterService{cp: cp} +} + +// Remove deletes any movie resource for given key. +func (service MovieSetterService) Remove(setter MovieBlockSetter, key resource.Key) { + setter.DelResource(key.Lang, key.ID) +} + +// Set exports the given container. +func (service MovieSetterService) Set(setter MovieBlockSetter, key resource.Key, container movie.Container) { + buf := bytes.NewBuffer(nil) + _ = movie.Write(buf, container, service.cp) + setter.SetResourceBlocks(key.Lang, key.ID, [][]byte{buf.Bytes()}) +} diff --git a/modding/hacked/ss1/edit/media/MovieViewerService.go b/modding/hacked/ss1/edit/media/MovieViewerService.go new file mode 100644 index 0000000..b0bf57c --- /dev/null +++ b/modding/hacked/ss1/edit/media/MovieViewerService.go @@ -0,0 +1,68 @@ +package media + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// MovieBlockGetter provides raw data of blocks. +type MovieBlockGetter interface { + ModifiedBlock(lang resource.Language, id resource.ID, index int) []byte +} + +// MovieViewerService provides read-only access to movie resources. +type MovieViewerService struct { + movieCache *movie.Cache + getter MovieBlockGetter +} + +// NewMovieViewerService returns a new instance. +func NewMovieViewerService(movieCache *movie.Cache, getter MovieBlockGetter) MovieViewerService { + return MovieViewerService{ + movieCache: movieCache, + getter: getter, + } +} + +// Modified returns true if the identified text resource is marked as modified. +func (service MovieViewerService) Modified(key resource.Key) bool { + return len(service.getter.ModifiedBlock(key.Lang, key.ID, key.Index)) > 0 +} + +// SizeWarning returns true if the given movie is larger than the underlying archive would support. +func (service MovieViewerService) SizeWarning(key resource.Key) bool { + return service.movieCache.SizeWarning(key) +} + +// Container returns the base container of the movie. +func (service MovieViewerService) Container(key resource.Key) (movie.Container, error) { + return service.movieCache.Container(key) +} + +// Video returns the visual component associated with the given key. +func (service MovieViewerService) Video(key resource.Key) []movie.Scene { + currentValue, cacheErr := service.movieCache.Video(key) + if cacheErr != nil { + return nil + } + return currentValue +} + +// Audio returns the audio data associated with the given key. +func (service MovieViewerService) Audio(key resource.Key) audio.L8 { + currentValue, cacheErr := service.movieCache.Audio(key) + if cacheErr != nil { + return audio.L8{} + } + return currentValue +} + +// Subtitles returns the subtitles associated with the given key. +func (service MovieViewerService) Subtitles(key resource.Key, language resource.Language) movie.SubtitleList { + currentValue, cacheErr := service.movieCache.Subtitles(key, language) + if cacheErr != nil { + return movie.SubtitleList{} + } + return currentValue +} diff --git a/modding/hacked/ss1/edit/media/SoundEffectSetterService.go b/modding/hacked/ss1/edit/media/SoundEffectSetterService.go new file mode 100644 index 0000000..3297fa8 --- /dev/null +++ b/modding/hacked/ss1/edit/media/SoundEffectSetterService.go @@ -0,0 +1,42 @@ +package media + +import ( + "bytes" + + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/audio/voc" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// SoundEffectBlockSetter modifies storage of raw resource data. +type SoundEffectBlockSetter interface { + SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte) + DelResource(lang resource.Language, id resource.ID) +} + +// SoundEffectSetterService provides methods to change sound resources. +type SoundEffectSetterService struct{} + +// NewSoundSetterService returns a new instance. +func NewSoundSetterService() SoundEffectSetterService { + return SoundEffectSetterService{} +} + +// Remove deletes any audio resource for given key. +func (service SoundEffectSetterService) Remove(setter SoundEffectBlockSetter, key resource.Key) { + setter.DelResource(key.Lang, key.ID.Plus(key.Index)) +} + +// Clear resets the identified audio resource to a silent one-sample audio. +func (service SoundEffectSetterService) Clear(setter SoundEffectBlockSetter, key resource.Key) { + silence := audio.L8{SampleRate: 22050, Samples: []byte{0x80}} + service.Set(setter, key, silence) +} + +// Set stores the given sound as the identified resource. +func (service SoundEffectSetterService) Set(setter SoundEffectBlockSetter, key resource.Key, data audio.L8) { + buf := bytes.NewBuffer(nil) + _ = voc.Save(buf, data.SampleRate, data.Samples) + blockData := [][]byte{buf.Bytes()} + setter.SetResourceBlocks(key.Lang, key.ID.Plus(key.Index), blockData) +} diff --git a/modding/hacked/ss1/edit/media/SoundEffectViewerService.go b/modding/hacked/ss1/edit/media/SoundEffectViewerService.go new file mode 100644 index 0000000..e71276b --- /dev/null +++ b/modding/hacked/ss1/edit/media/SoundEffectViewerService.go @@ -0,0 +1,38 @@ +package media + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/sound" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// SoundEffectBlockGetter provides raw data of blocks. +type SoundEffectBlockGetter interface { + ModifiedBlock(lang resource.Language, id resource.ID, index int) []byte +} + +// SoundEffectViewerService provides read-only access to audio resources. +type SoundEffectViewerService struct { + soundCache *sound.EffectCache + getter SoundEffectBlockGetter +} + +// NewSoundViewerService returns a new instance. +func NewSoundViewerService(soundCache *sound.EffectCache, getter SoundEffectBlockGetter) SoundEffectViewerService { + return SoundEffectViewerService{ + soundCache: soundCache, + getter: getter, + } +} + +// Audio returns the audio data associated with the given key. +func (service SoundEffectViewerService) Audio(key resource.Key) audio.L8 { + sound, _ := service.soundCache.Audio(key) + return sound +} + +// Modified returns true if the identified sound resource is marked as modified. +func (service SoundEffectViewerService) Modified(key resource.Key) bool { + data := service.getter.ModifiedBlock(key.Lang, key.ID.Plus(key.Index), 0) + return len(data) > 0 +} diff --git a/modding/hacked/ss1/edit/media/TextSetterService.go b/modding/hacked/ss1/edit/media/TextSetterService.go new file mode 100644 index 0000000..60f8f1e --- /dev/null +++ b/modding/hacked/ss1/edit/media/TextSetterService.go @@ -0,0 +1,59 @@ +package media + +import ( + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// TextBlockSetter modifies storage of raw resource data. +type TextBlockSetter interface { + SetResourceBlock(lang resource.Language, id resource.ID, index int, data []byte) + SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte) + DelResource(lang resource.Language, id resource.ID) +} + +// TextSetterService provides methods to change text resources. +type TextSetterService struct { + cp text.Codepage +} + +// NewTextSetterService returns a new instance. +func NewTextSetterService(cp text.Codepage) TextSetterService { + return TextSetterService{ + cp: cp, + } +} + +// Remove deletes any text resource for given key. +func (service TextSetterService) Remove(setter TextBlockSetter, key resource.Key) { + info, _ := ids.Info(key.ID) + if info.List { + setter.SetResourceBlock(key.Lang, key.ID, key.Index, nil) + } else { + id := key.ID.Plus(key.Index) + setter.DelResource(key.Lang, id) + } +} + +// Clear resets the identified text resource to an empty string. +func (service TextSetterService) Clear(setter TextBlockSetter, key resource.Key) { + service.Set(setter, key, "") +} + +// Set stores the given text as the identified resource. +func (service TextSetterService) Set(setter TextBlockSetter, key resource.Key, value string) { + blockedValue := text.Blocked(value) + info, _ := ids.Info(key.ID) + if info.List { + newData := service.cp.Encode(blockedValue[0]) + setter.SetResourceBlock(key.Lang, key.ID, key.Index, newData) + } else { + newData := make([][]byte, len(blockedValue)) + for index, blockLine := range blockedValue { + newData[index] = service.cp.Encode(blockLine) + } + id := key.ID.Plus(key.Index) + setter.SetResourceBlocks(key.Lang, id, newData) + } +} diff --git a/modding/hacked/ss1/edit/media/TextViewerService.go b/modding/hacked/ss1/edit/media/TextViewerService.go new file mode 100644 index 0000000..44255d0 --- /dev/null +++ b/modding/hacked/ss1/edit/media/TextViewerService.go @@ -0,0 +1,54 @@ +package media + +import ( + "github.com/inkyblackness/hacked/ss1/content/text" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// TextBlockGetter provides raw data of blocks. +type TextBlockGetter interface { + ModifiedBlock(lang resource.Language, id resource.ID, index int) []byte + ModifiedBlocks(lang resource.Language, id resource.ID) [][]byte +} + +// TextViewerService provides read-only access to text resources. +type TextViewerService struct { + lineCache *text.Cache + pageCache *text.Cache + getter TextBlockGetter +} + +// NewTextViewerService returns a new instance. +func NewTextViewerService(lineCache, pageCache *text.Cache, getter TextBlockGetter) TextViewerService { + return TextViewerService{ + lineCache: lineCache, + pageCache: pageCache, + getter: getter, + } +} + +// Text returns the text data associated with the given key. +func (service TextViewerService) Text(key resource.Key) string { + var cache *text.Cache + resourceInfo, existing := ids.Info(key.ID) + if !existing || resourceInfo.List { + cache = service.lineCache + } else { + cache = service.pageCache + } + currentValue, cacheErr := cache.Text(key) + if cacheErr != nil { + currentValue = "" + } + return currentValue +} + +// Modified returns true if the identified text resource is marked as modified. +func (service TextViewerService) Modified(key resource.Key) bool { + info, _ := ids.Info(key.ID) + if info.List { + return len(service.getter.ModifiedBlock(key.Lang, key.ID, key.Index)) > 0 + } + return len(service.getter.ModifiedBlocks(key.Lang, key.ID.Plus(key.Index))) > 0 +} diff --git a/modding/hacked/ss1/edit/media/doc.go b/modding/hacked/ss1/edit/media/doc.go new file mode 100644 index 0000000..886b6d6 --- /dev/null +++ b/modding/hacked/ss1/edit/media/doc.go @@ -0,0 +1,2 @@ +// Package media contains low-level media accessors and modifier. +package media diff --git a/modding/hacked/ss1/edit/undoable/AugmentedText.go b/modding/hacked/ss1/edit/undoable/AugmentedText.go new file mode 100644 index 0000000..efcbb37 --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/AugmentedText.go @@ -0,0 +1,91 @@ +package undoable + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +// AugmentedTextService provides read/write functionality with undo capability. +type AugmentedTextService struct { + wrapped edit.AugmentedTextService + commander cmd.Commander +} + +// NewAugmentedTextService returns a new instance of a service. +func NewAugmentedTextService(wrapped edit.AugmentedTextService, commander cmd.Commander) AugmentedTextService { + return AugmentedTextService{ + wrapped: wrapped, + commander: commander, + } +} + +// WithAudio returns true if the identified resource is one with an audio component. +func (service AugmentedTextService) WithAudio(key resource.Key) bool { + return service.wrapped.WithAudio(key) +} + +// Text returns the textual value of the identified text resource. +func (service AugmentedTextService) Text(key resource.Key) string { + return service.wrapped.Text(key) +} + +// RequestSetText queues the change to update the text. +func (service AugmentedTextService) RequestSetText(key resource.Key, value string, restoreFunc func()) { + service.requestCommand( + func(setter edit.AugmentedTextBlockSetter) { + service.wrapped.SetText(setter, key, value) + }, + service.wrapped.RestoreTextFunc(key), + restoreFunc) +} + +// Sound returns the audio value of the identified text resource. +// In case the text resource has no audio, an empty sound will be returned. +func (service AugmentedTextService) Sound(key resource.Key) audio.L8 { + return service.wrapped.Sound(key) +} + +// RequestSetSound queues the change to update the sound. +func (service AugmentedTextService) RequestSetSound(key resource.Key, sound audio.L8, restoreFunc func()) { + service.requestCommand( + func(setter edit.AugmentedTextBlockSetter) { + service.wrapped.SetSound(setter, key, sound) + }, + service.wrapped.RestoreSoundFunc(key), + restoreFunc) +} + +// RequestClear queues the change to set both the text and the sound empty. +func (service AugmentedTextService) RequestClear(key resource.Key, restoreFunc func()) { + service.requestCommand( + func(setter edit.AugmentedTextBlockSetter) { + service.wrapped.Clear(setter, key) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// RequestRemove queues the change to remove both the text and the sound from the storage. +func (service AugmentedTextService) RequestRemove(key resource.Key, restoreFunc func()) { + service.requestCommand( + func(setter edit.AugmentedTextBlockSetter) { + service.wrapped.Remove(setter, key) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +func (service AugmentedTextService) requestCommand( + forward func(modder edit.AugmentedTextBlockSetter), + reverse func(modder edit.AugmentedTextBlockSetter), + restore func()) { + c := command{ + forward: func(modder world.Modder) { forward(modder) }, + reverse: func(modder world.Modder) { reverse(modder) }, + restore: restore, + } + service.commander.Queue(c) +} diff --git a/modding/hacked/ss1/edit/undoable/Command.go b/modding/hacked/ss1/edit/undoable/Command.go new file mode 100644 index 0000000..b0e81c4 --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/Command.go @@ -0,0 +1,25 @@ +package undoable + +import ( + "github.com/inkyblackness/hacked/ss1/world" +) + +type command struct { + forward func(world.Modder) + reverse func(world.Modder) + restore func() +} + +func (c command) Do(modder world.Modder) error { + return c.perform(modder, c.forward) +} + +func (c command) Undo(modder world.Modder) error { + return c.perform(modder, c.reverse) +} + +func (c command) perform(modder world.Modder, callback func(setter world.Modder)) error { + callback(modder) + c.restore() + return nil +} diff --git a/modding/hacked/ss1/edit/undoable/MovieService.go b/modding/hacked/ss1/edit/undoable/MovieService.go new file mode 100644 index 0000000..0eee626 --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/MovieService.go @@ -0,0 +1,131 @@ +package undoable + +import ( + "time" + + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/content/movie" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/media" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +// MovieService provides read/write functionality with undo capability. +type MovieService struct { + wrapped edit.MovieService + commander cmd.Commander +} + +// NewMovieService returns a new instance of a service. +func NewMovieService(wrapped edit.MovieService, commander cmd.Commander) MovieService { + return MovieService{ + wrapped: wrapped, + commander: commander, + } +} + +// SizeWarning returns true if the given movie is larger than the underlying archive would support. +func (service MovieService) SizeWarning(key resource.Key) bool { + return service.wrapped.SizeWarning(key) +} + +// Video returns the video component of identified movie. +func (service MovieService) Video(key resource.Key) []movie.Scene { + return service.wrapped.Video(key) +} + +// RequestMoveSceneEarlier queues to move the identified scene earlier. +func (service MovieService) RequestMoveSceneEarlier(key resource.Key, scene int, restoreFunc func()) { + service.requestCommand( + func(setter media.MovieBlockSetter) { + service.wrapped.MoveSceneEarlier(setter, key, scene) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// RequestMoveSceneLater queues to move the identified scene later. +func (service MovieService) RequestMoveSceneLater(key resource.Key, scene int, restoreFunc func()) { + service.requestCommand( + func(setter media.MovieBlockSetter) { + service.wrapped.MoveSceneLater(setter, key, scene) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// RequestAddScene queues to add the given scene at the end of the movie. +func (service MovieService) RequestAddScene(key resource.Key, scene movie.HighResScene, restoreFunc func()) { + service.requestCommand( + func(setter media.MovieBlockSetter) { + service.wrapped.AddScene(setter, key, scene) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// RequestRemoveScene queues to remove the identified scene. +func (service MovieService) RequestRemoveScene(key resource.Key, scene int, restoreFunc func()) { + service.requestCommand( + func(setter media.MovieBlockSetter) { + service.wrapped.RemoveScene(setter, key, scene) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// RequestSetSceneFramesDisplayTime requests to set the display time for each frame. +func (service MovieService) RequestSetSceneFramesDisplayTime(key resource.Key, + scene int, displayTime time.Duration, restoreFunc func()) { + service.requestCommand( + func(setter media.MovieBlockSetter) { + service.wrapped.SetSceneFramesDisplayTime(setter, key, scene, displayTime) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// Audio returns the audio component of identified movie. +func (service MovieService) Audio(key resource.Key) audio.L8 { + return service.wrapped.Audio(key) +} + +// RequestSetAudio queues the change to update the audio track. +func (service MovieService) RequestSetAudio(key resource.Key, soundData audio.L8, restoreFunc func()) { + service.requestCommand( + func(setter media.MovieBlockSetter) { + service.wrapped.SetAudio(setter, key, soundData) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// Subtitles returns the subtitles associated with the given key. +func (service MovieService) Subtitles(key resource.Key, language resource.Language) movie.SubtitleList { + return service.wrapped.Subtitles(key, language) +} + +// RequestSetSubtitles queues the change to update subtitles. +func (service MovieService) RequestSetSubtitles(key resource.Key, + language resource.Language, subtitles movie.SubtitleList, restoreFunc func()) { + service.requestCommand( + func(setter media.MovieBlockSetter) { + service.wrapped.SetSubtitles(setter, key, language, subtitles) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +func (service MovieService) requestCommand( + forward func(modder media.MovieBlockSetter), + reverse func(modder media.MovieBlockSetter), + restore func()) { + c := command{ + forward: func(modder world.Modder) { forward(modder) }, + reverse: func(modder world.Modder) { reverse(modder) }, + restore: restore, + } + service.commander.Queue(c) +} diff --git a/modding/hacked/ss1/edit/undoable/SoundEffectService.go b/modding/hacked/ss1/edit/undoable/SoundEffectService.go new file mode 100644 index 0000000..bc4ca2a --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/SoundEffectService.go @@ -0,0 +1,76 @@ +package undoable + +import ( + "github.com/inkyblackness/hacked/ss1/content/audio" + "github.com/inkyblackness/hacked/ss1/edit" + "github.com/inkyblackness/hacked/ss1/edit/media" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" +) + +// SoundEffectService provides read/write functionality with undo capability. +type SoundEffectService struct { + wrapped edit.SoundEffectService + commander cmd.Commander +} + +// NewSoundEffectService returns a new instance of a service. +func NewSoundEffectService(wrapped edit.SoundEffectService, commander cmd.Commander) SoundEffectService { + return SoundEffectService{ + wrapped: wrapped, + commander: commander, + } +} + +// Modified returns true if the identified sound resource is marked as modified. +func (service SoundEffectService) Modified(key resource.Key) bool { + return service.wrapped.Modified(key) +} + +// RequestRemove queues to erase the sound from the resources. +func (service SoundEffectService) RequestRemove(key resource.Key, restoreFunc func()) { + service.requestCommand( + func(setter media.SoundEffectBlockSetter) { + service.wrapped.Remove(setter, key) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// RequestClear queues to reset the identified audio resource to a silent one-sample audio. +func (service SoundEffectService) RequestClear(key resource.Key, restoreFunc func()) { + service.requestCommand( + func(setter media.SoundEffectBlockSetter) { + service.wrapped.Clear(setter, key) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +// Audio returns the audio component of identified sound effect. +func (service SoundEffectService) Audio(key resource.Key) audio.L8 { + return service.wrapped.Audio(key) +} + +// RequestSetAudio queues the change to update the audio. +func (service SoundEffectService) RequestSetAudio(key resource.Key, data audio.L8, restoreFunc func()) { + service.requestCommand( + func(setter media.SoundEffectBlockSetter) { + service.wrapped.SetAudio(setter, key, data) + }, + service.wrapped.RestoreFunc(key), + restoreFunc) +} + +func (service SoundEffectService) requestCommand( + forward func(modder media.SoundEffectBlockSetter), + reverse func(modder media.SoundEffectBlockSetter), + restore func()) { + c := command{ + forward: func(modder world.Modder) { forward(modder) }, + reverse: func(modder world.Modder) { reverse(modder) }, + restore: restore, + } + service.commander.Queue(c) +} diff --git a/modding/hacked/ss1/edit/undoable/cmd/Command.go b/modding/hacked/ss1/edit/undoable/cmd/Command.go new file mode 100644 index 0000000..6b0d9ac --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/cmd/Command.go @@ -0,0 +1,15 @@ +package cmd + +import "github.com/inkyblackness/hacked/ss1/world" + +// Command describes an action that can be performed, undone, and redone. +type Command interface { + // Do performs the action. + // An error is returned if the action could not be performed. + Do(modder world.Modder) error + + // Undo reverses its action to restore the environment to the previous state. + // An error is returned if the action could not be successfully undone. The + // environment may not be in the state as before in an error occurred. + Undo(modder world.Modder) error +} diff --git a/modding/hacked/ss1/edit/undoable/cmd/Commander.go b/modding/hacked/ss1/edit/undoable/cmd/Commander.go new file mode 100644 index 0000000..9a82bff --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/cmd/Commander.go @@ -0,0 +1,6 @@ +package cmd + +// Commander tries to execute the given command. +type Commander interface { + Queue(command Command) +} diff --git a/modding/hacked/ss1/edit/undoable/cmd/List.go b/modding/hacked/ss1/edit/undoable/cmd/List.go new file mode 100644 index 0000000..6379f16 --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/cmd/List.go @@ -0,0 +1,30 @@ +package cmd + +import "github.com/inkyblackness/hacked/ss1/world" + +// List is a sequence of commands, which are executed as one step. +type List []Command + +// Do performs the entries in the list in ascending order. +// If an entry returns an error, the iteration is aborted and that error is returned. +func (list List) Do(modder world.Modder) error { + for _, entry := range list { + err := entry.Do(modder) + if err != nil { + return err + } + } + return nil +} + +// Undo performs the entries in the list in descending order. +// If an entry returns an error, the iteration is aborted and that error is returned. +func (list List) Undo(modder world.Modder) error { + for i := len(list) - 1; i >= 0; i-- { + err := list[i].Undo(modder) + if err != nil { + return err + } + } + return nil +} diff --git a/modding/hacked/ss1/edit/undoable/cmd/Stack.go b/modding/hacked/ss1/edit/undoable/cmd/Stack.go new file mode 100644 index 0000000..0eea84d --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/cmd/Stack.go @@ -0,0 +1,100 @@ +package cmd + +import "github.com/inkyblackness/hacked/ss1/world" + +type stackEntry struct { + link *stackEntry + cmd Command +} + +// Stack describes a list of commands. The stack allows to sequentially +// undo and redo stacked commands. +// It essentially stores two lists: a list of commands to undo, and +// another of commands to redo. +// Modifying stack functions will panic if they are called while already in use. +type Stack struct { + lockedBy string + undoList *stackEntry + redoList *stackEntry +} + +// Perform executes the given command and puts it on the stack +// if the command was successful. +// This function also clears the list of commands to be redone. +func (stack *Stack) Perform(cmd Command, modder world.Modder) error { + stack.lock("Perform") + defer stack.unlock() + + err := cmd.Do(modder) + if err != nil { + return err + } + stack.undoList = &stackEntry{link: stack.undoList, cmd: cmd} + stack.redoList = nil + return nil +} + +// CanUndo returns true if there is at least one more command that can be undone. +func (stack *Stack) CanUndo() bool { + return stack.undoList != nil +} + +// Undo attempts to undo the previous command on the list. +// If there is no further command to undo, nothing happens. +// An error is returned if the command failed. In this case, the stack is +// unchanged and a further attempt to undo will try the same command again. +func (stack *Stack) Undo(modder world.Modder) error { + stack.lock("Undo") + defer stack.unlock() + + if stack.undoList == nil { + return nil + } + entry := stack.undoList + err := entry.cmd.Undo(modder) + if err != nil { + return err + } + stack.undoList = entry.link + entry.link = stack.redoList + stack.redoList = entry + return nil +} + +// CanRedo returns true if there is at least one more command that can be redone. +func (stack *Stack) CanRedo() bool { + return stack.redoList != nil +} + +// Redo attempts to perform the next command on the redo list. +// If there is no further command to redo, nothing happens. +// An error is returned if the command failed. In this case, the stack is +// unchanged and a further attempt to redo will try the same command again. +func (stack *Stack) Redo(modder world.Modder) error { + stack.lock("Redo") + defer stack.unlock() + + if stack.redoList == nil { + return nil + } + entry := stack.redoList + err := entry.cmd.Do(modder) + if err != nil { + return err + } + stack.redoList = entry.link + entry.link = stack.undoList + stack.undoList = entry + return nil +} + +func (stack *Stack) lock(by string) { + if stack.lockedBy != "" { + panic("Stack already in use by <" + stack.lockedBy + ">") + } + stack.lockedBy = by +} + +func (stack *Stack) unlock() { + stack.lockedBy = "" +} diff --git a/modding/hacked/ss1/edit/undoable/cmd/Stack_test.go b/modding/hacked/ss1/edit/undoable/cmd/Stack_test.go new file mode 100644 index 0000000..7972886 --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/cmd/Stack_test.go @@ -0,0 +1,348 @@ +package cmd_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/world" +) + +type TestCommand struct { + name string + + executed int + reverted int + + pendingError error + task func() +} + +func (cmd *TestCommand) Do(modder world.Modder) error { + cmd.executed++ + return cmd.run() +} + +func (cmd *TestCommand) Undo(modder world.Modder) error { + cmd.reverted++ + return cmd.run() +} + +func (cmd *TestCommand) run() (err error) { + if cmd.task != nil { + cmd.task() + } + err = cmd.pendingError + cmd.pendingError = nil + return +} + +type StackSuite struct { + suite.Suite + + stack cmd.Stack + commands map[string]*TestCommand +} + +func TestStackSuite(t *testing.T) { + suite.Run(t, new(StackSuite)) +} + +func (suite *StackSuite) SetupTest() { + suite.stack = cmd.Stack{} + suite.commands = make(map[string]*TestCommand) +} + +func (suite *StackSuite) TestNewStackCantDoAnything() { + suite.thenStackShouldNotSupportRedo() + suite.thenStackShouldNotSupportUndo() +} + +func (suite *StackSuite) TestPerformExecutesCommand() { + suite.whenPerforming(suite.aCommand("cmd1")) + suite.thenCommandShouldHaveBeenExecuted("cmd1") +} + +func (suite *StackSuite) TestPerformAllowsUndoIfSuccessful() { + suite.whenPerforming(suite.aCommand("cmd1")) + suite.thenStackShouldSupportUndo() +} + +func (suite *StackSuite) TestPerformIgnoresCommandIfItFails() { + suite.whenPerforming(suite.aCommandReturningError()) + suite.thenStackShouldNotSupportUndo() +} + +func (suite *StackSuite) TestPerformReturnsErrorOfCommand() { + err := ss1.StringError("fail first time") + suite.thenPerformShouldReturnError(suite.aCommandReturning(err), err) +} + +func (suite *StackSuite) TestUndoRevertsCommand() { + suite.givenCommandWasPerformed("cmd1") + suite.whenUndoing() + suite.thenCommandShouldHaveBeenReverted("cmd1") +} + +func (suite *StackSuite) TestUndoRevertsCommandOnlyOnce() { + suite.givenCommandWasPerformed("cmd1") + suite.givenUndoWasCalledTimes(1) + suite.whenUndoing() + suite.thenCommandShouldHaveBeenRevertedTimes("cmd1", 1) +} + +func (suite *StackSuite) TestUndoRevertsCommandsInSequence() { + suite.givenCommandWasPerformed("cmd1") + suite.givenCommandWasPerformed("cmd2") + + suite.whenUndoing() + suite.thenCommandShouldHaveBeenReverted("cmd2") + + suite.whenUndoing() + suite.thenCommandShouldHaveBeenReverted("cmd1") +} + +func (suite *StackSuite) TestUndoLeavesStackUnchangedIfCommandFails() { + suite.givenCommandWasPerformed("cmd1") + suite.givenCommandWasPerformed("cmd2") + suite.givenCommandWillFail("cmd2") + suite.givenUndoWasCalledTimes(1) + suite.whenUndoing() + suite.thenCommandShouldHaveBeenRevertedTimes("cmd2", 2) +} + +func (suite *StackSuite) TestUndoEnablesRedo() { + suite.givenCommandWasPerformed("cmd1") + suite.whenUndoing() + suite.thenStackShouldSupportRedo() +} + +func (suite *StackSuite) TestRedoExecutesCommandAgain() { + suite.givenCommandWasPerformed("cmd1") + suite.givenUndoWasCalledTimes(1) + suite.whenRedoing() + suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 2) +} + +func (suite *StackSuite) TestRedoExecutesCommandOnlyOnce() { + suite.givenCommandWasPerformed("cmd1") + suite.givenUndoWasCalledTimes(1) + suite.givenRedoWasCalledTimes(1) + suite.whenRedoing() + suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 2) +} + +func (suite *StackSuite) TestRedoExecutesCommandsInSequence() { + suite.givenCommandWasPerformed("cmd1") + suite.givenCommandWasPerformed("cmd2") + suite.givenUndoWasCalledTimes(2) + + suite.whenRedoing() + suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 2) + + suite.whenRedoing() + suite.thenCommandShouldHaveBeenExecutedTimes("cmd2", 2) +} + +func (suite *StackSuite) TestRedoLeavesStackUnchangedIfCommandFails() { + suite.givenCommandWasPerformed("cmd1") + suite.givenUndoWasCalledTimes(1) + suite.givenCommandWillFail("cmd1") + suite.givenRedoWasCalledTimes(1) + suite.whenRedoing() + suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 3) +} + +func (suite *StackSuite) TestRedoMakesCommandsUndoableAgain() { + suite.givenCommandWasPerformed("cmd1") + suite.givenUndoWasCalledTimes(1) + suite.givenRedoWasCalledTimes(1) + suite.whenUndoing() + suite.thenCommandShouldHaveBeenRevertedTimes("cmd1", 2) +} + +func (suite *StackSuite) TestPerformDropsPendingRedoStack() { + suite.givenCommandWasPerformed("cmd1") + suite.givenUndoWasCalledTimes(1) + + suite.whenPerforming(suite.aCommand("cmd2")) + suite.thenStackShouldNotSupportRedo() + + suite.whenRedoing() + suite.thenCommandShouldHaveBeenExecutedTimes("cmd1", 1) +} + +func (suite *StackSuite) TestPerformPanicsIfStackIsInUse() { + callPerform := func(name string) func() { + var times int + return func() { + require.Equal(suite.T(), 0, times, "Re-entrant call detected of <"+name+">. Should actually panic.") + times++ + + assert.Panics(suite.T(), func() { + _ = suite.stack.Perform(suite.aCommand(name+"-nested"), nil) + }, "Perform during function <"+name+"> should panic") + } + } + + suite.assertPanics(callPerform) +} + +func (suite *StackSuite) TestUndoPanicsIfStackIsInUse() { + callUndo := func(name string) func() { + var times int + return func() { + require.Equal(suite.T(), 0, times, "Re-entrant call detected of <"+name+">. Should actually panic.") + times++ + + assert.Panics(suite.T(), func() { + _ = suite.stack.Undo(nil) + }, "Undo during function <"+name+"> should panic") + } + } + + suite.assertPanics(callUndo) +} + +func (suite *StackSuite) TestRedoPanicsIfStackIsInUse() { + callRedo := func(name string) func() { + var times int + return func() { + require.Equal(suite.T(), 0, times, "Re-entrant call detected of <"+name+">. Should actually panic.") + times++ + + assert.Panics(suite.T(), func() { + _ = suite.stack.Undo(nil) + }, "Redo during function <"+name+"> should panic") + } + } + + suite.assertPanics(callRedo) +} + +func (suite *StackSuite) assertPanics(taskFor func(string) func()) { + cmd1 := suite.aCommandExecuting("cmd1", taskFor("Perform")) + suite.whenPerforming(cmd1) + + suite.givenCommandWasPerformed("cmd2") + suite.givenCommandExecutes("cmd2", taskFor("Undo")) + suite.whenUndoing() + + suite.givenCommandWasPerformed("cmd3") + suite.givenUndoWasCalledTimes(1) + suite.givenCommandExecutes("cmd3", taskFor("Redo")) + suite.whenRedoing() +} + +func (suite *StackSuite) givenCommandWasPerformed(name string) { + suite.whenPerforming(suite.aCommand(name)) +} + +func (suite *StackSuite) givenUndoWasCalledTimes(times int) { + for i := 0; i < times; i++ { + _ = suite.stack.Undo(nil) + } +} + +func (suite *StackSuite) givenRedoWasCalledTimes(times int) { + for i := 0; i < times; i++ { + _ = suite.stack.Redo(nil) + } +} + +func (suite *StackSuite) givenCommandWillFail(name string) { + suite.pastCommand(name).pendingError = ss1.StringError("failing") +} + +func (suite *StackSuite) givenCommandExecutes(name string, task func()) { + cmd := suite.pastCommand(name) + cmd.task = task +} + +func (suite *StackSuite) whenUndoing() { + _ = suite.stack.Undo(nil) +} + +func (suite *StackSuite) whenRedoing() { + _ = suite.stack.Redo(nil) +} + +func (suite *StackSuite) whenPerforming(command cmd.Command) { + _ = suite.stack.Perform(command, nil) +} + +func (suite *StackSuite) thenStackShouldSupportRedo() { + assert.True(suite.T(), suite.stack.CanRedo(), "Stack should be able to redo") +} + +func (suite *StackSuite) thenStackShouldNotSupportRedo() { + assert.False(suite.T(), suite.stack.CanRedo(), "Stack should not be able to redo") +} + +func (suite *StackSuite) thenStackShouldSupportUndo() { + assert.True(suite.T(), suite.stack.CanUndo(), "Stack should be able to undo") +} + +func (suite *StackSuite) thenStackShouldNotSupportUndo() { + assert.False(suite.T(), suite.stack.CanUndo(), "Stack should not be able to undo") +} + +func (suite *StackSuite) thenCommandShouldHaveBeenExecuted(name string) { + cmd := suite.pastCommand(name) + assert.True(suite.T(), cmd.executed > 0, "Command <"+name+"> should have been executed at least once") +} + +func (suite *StackSuite) thenCommandShouldHaveBeenExecutedTimes(name string, expected int) { + cmd := suite.pastCommand(name) + assert.Equal(suite.T(), expected, cmd.executed) +} + +func (suite *StackSuite) thenCommandShouldHaveBeenReverted(name string) { + cmd := suite.pastCommand(name) + assert.True(suite.T(), cmd.reverted > 0, "Command <"+name+"> should have been reverted at least once") +} + +func (suite *StackSuite) thenCommandShouldHaveBeenRevertedTimes(name string, expected int) { + cmd := suite.pastCommand(name) + assert.Equal(suite.T(), expected, cmd.reverted) +} + +func (suite *StackSuite) thenPerformShouldReturnError(cmd cmd.Command, expected error) { + result := suite.stack.Perform(cmd, nil) + assert.Equal(suite.T(), expected, result) +} + +func (suite *StackSuite) aCommand(name string) cmd.Command { + cmd := &TestCommand{name: name} + suite.commands[name] = cmd + return cmd +} + +func (suite *StackSuite) aCommandExecuting(name string, task func()) cmd.Command { + cmd := &TestCommand{name: name, task: task} + suite.commands[name] = cmd + return cmd +} + +func (suite *StackSuite) aCommandReturningError() cmd.Command { + return suite.aCommandReturning(ss1.StringError("fail")) +} + +func (suite *StackSuite) aCommandReturning(err error) cmd.Command { + name := "unnamed" + cmd := &TestCommand{name: name, pendingError: err} + suite.commands[name] = cmd + return cmd +} + +func (suite *StackSuite) pastCommand(name string) *TestCommand { + cmd, found := suite.commands[name] + if !found { + require.True(suite.T(), found, "Command not found <"+name+"> - test is wrong") + } + return cmd +} diff --git a/modding/hacked/ss1/edit/undoable/cmd/Transaction.go b/modding/hacked/ss1/edit/undoable/cmd/Transaction.go new file mode 100644 index 0000000..157f1ee --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/cmd/Transaction.go @@ -0,0 +1,157 @@ +package cmd + +import ( + "fmt" + + "github.com/inkyblackness/hacked/ss1/world" +) + +// Task represents a modification on given modder. +type Task func(modder world.Modder) error + +// TransactionError is returned from a transaction execution if a task failed. +type TransactionError struct { + // Name is that of the transaction, if it was set. + Name string + // Index identifies the list index of tasks. + Index int + // Nested is the error returned from the task. In case of nested commands, this may be another TransactionError. + Nested error +} + +// Error returns the path of the transaction task, combined with the nested error text. +func (err TransactionError) Error() string { + return fmt.Sprintf("%s[%d]: %v", err.Name, err.Index, err.Nested) +} + +// Unwrap returns the nested error, to support diving into nested errors. +func (err TransactionError) Unwrap() error { + return err.Nested +} + +// Transaction is a named list of forward and reverse tasks. +// Transaction provides execution of the tasks as a Command. +type Transaction struct { + Name string + Forward []Task + Reverse []Task +} + +// Do executes the forward tasks in series. The first task to return an error aborts the iteration. +// A TransactionError is returned in case of aborted iteration. +func (txn Transaction) Do(modder world.Modder) error { + return txn.perform(txn.Forward, modder) +} + +// Undo executes the reverse tasks in series. The first task to return an error aborts the iteration. +// A TransactionError is returned in case of aborted iteration. +func (txn Transaction) Undo(modder world.Modder) error { + return txn.perform(txn.Reverse, modder) +} + +func (txn Transaction) perform(tasks []Task, modder world.Modder) error { + for i, task := range tasks { + err := task(modder) + if err != nil { + return TransactionError{ + Name: txn.Name, + Index: i, + Nested: err, + } + } + } + return nil +} + +// TransactionModifier is a function to change a transaction while it is being built. +type TransactionModifier func(*Transaction) error + +// Registry allows to build commands as transactions with modifier functions. +type Registry interface { + Commander + Register(modifier ...TransactionModifier) error +} + +// TransactionBuilder provides a way to register a command at a commander with +// possibly nested actions. +type TransactionBuilder struct { + Commander Commander + active *Transaction +} + +// Register creates a new transaction based on given modifier. +func (builder *TransactionBuilder) Register(modifier ...TransactionModifier) error { + previous := builder.active + current := &Transaction{} + + builder.active = current + for _, mod := range modifier { + err := mod(current) + if err != nil { + return err + } + } + builder.active = previous + + // invert reverse task for easier iteration + for left, right := 0, len(current.Reverse)-1; left < right; left, right = left+1, right-1 { + current.Reverse[left], current.Reverse[right] = current.Reverse[right], current.Reverse[left] + } + + builder.Queue(current) + return nil +} + +// Queue implements the Commander interface to add given command to a currently built transaction. +// If there is no outer transaction being built, then the command is immediately forwarded to the commander. +// Avoid building a loop between TransactionBuilder instances, as this will result in a stack overflow. +func (builder *TransactionBuilder) Queue(command Command) { + if builder.active == nil { + builder.Commander.Queue(command) + return + } + + builder.active.Forward = append(builder.active.Forward, command.Do) + builder.active.Reverse = append(builder.active.Reverse, command.Undo) +} + +// Named allows to name the current level of transaction. +func Named(name string) TransactionModifier { + return func(txn *Transaction) error { + txn.Name = name + return nil + } +} + +// Forward creates a modifier for a task that is called when doing a command. +// If a nested command is added to the transaction after the forward function, then +// the nested task is performed after the given task. +// If a nested command is added to the transaction before the forward function, then +// the nested task is performed before the given task. +func Forward(task Task) TransactionModifier { + return func(txn *Transaction) error { + txn.Forward = append(txn.Forward, task) + return nil + } +} + +// Reverse creates a modifier for a task that is called when undoing a command. +// If a nested command is added to the transaction after the reverse function, then +// the nested task is performed before the given task. +// If a nested command is added to the transaction before the reverse function, then +// the nested task is performed after the given task. +func Reverse(task Task) TransactionModifier { + return func(txn *Transaction) error { + txn.Reverse = append(txn.Reverse, task) + return nil + } +} + +// Nested adds a further level to the currently registered transaction. +// Depending on the order of the modifier in the register, the corresponding tasks will be called +// in sequence. See other modifier. +func Nested(nested func() error) TransactionModifier { + return func(txn *Transaction) error { + return nested() + } +} diff --git a/modding/hacked/ss1/edit/undoable/cmd/Transaction_test.go b/modding/hacked/ss1/edit/undoable/cmd/Transaction_test.go new file mode 100644 index 0000000..f55f134 --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/cmd/Transaction_test.go @@ -0,0 +1,135 @@ +package cmd_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/edit/undoable/cmd" + "github.com/inkyblackness/hacked/ss1/world" +) + +func TestTransactionBuilder(t *testing.T) { + suite.Run(t, new(TransactionBuilderSuite)) +} + +type TransactionBuilderSuite struct { + suite.Suite + + builder cmd.TransactionBuilder + command cmd.Command + result []string + err error +} + +func (suite *TransactionBuilderSuite) SetupTest() { + suite.builder = cmd.TransactionBuilder{Commander: suite} + suite.command = nil + suite.result = nil + suite.err = nil +} + +func (suite *TransactionBuilderSuite) TestTransactionCallsForwardTasksFromLeftToRight() { + suite.givenRegisteredTransaction( + cmd.Forward(suite.aTask("fa")), + cmd.Forward(suite.aTask("fb")), + cmd.Reverse(suite.aTask("ra")), + ) + suite.whenCommandIsDone() + suite.thenResultShouldBe([]string{"fa", "fb"}) +} + +func (suite *TransactionBuilderSuite) TestTransactionCallsReverseTasksFromRightToLeft() { + suite.givenRegisteredTransaction( + cmd.Forward(suite.aTask("fa")), + cmd.Reverse(suite.aTask("ra")), + cmd.Reverse(suite.aTask("rb")), + ) + suite.whenCommandIsUndone() + suite.thenResultShouldBe([]string{"rb", "ra"}) +} + +func (suite *TransactionBuilderSuite) TestTransactionCallsNestedInForwardDirection() { + suite.givenRegisteredTransaction( + cmd.Forward(suite.aTask("fa")), + cmd.Nested(func() error { + return suite.builder.Register(cmd.Forward(suite.aTask("na"))) + }), + cmd.Forward(suite.aTask("fb")), + ) + suite.whenCommandIsDone() + suite.thenResultShouldBe([]string{"fa", "na", "fb"}) +} + +func (suite *TransactionBuilderSuite) TestTransactionCallsNestedInReverseDirection() { + suite.givenRegisteredTransaction( + cmd.Reverse(suite.aTask("ra")), + cmd.Nested(func() error { + return suite.builder.Register(cmd.Reverse(suite.aTask("na")), cmd.Reverse(suite.aTask("nb"))) + }), + cmd.Reverse(suite.aTask("rb")), + ) + suite.whenCommandIsUndone() + suite.thenResultShouldBe([]string{"rb", "nb", "na", "ra"}) +} + +func (suite *TransactionBuilderSuite) TestTransactionReturnsErrorWithNamedPath() { + suite.givenRegisteredTransaction( + cmd.Named("main"), + cmd.Forward(suite.aTask("fa")), + cmd.Nested(func() error { + return suite.builder.Register(cmd.Named("nested"), cmd.Forward(suite.aTaskReturning(ss1.StringError("fail")))) + }), + cmd.Forward(suite.aTask("fb")), + ) + suite.whenCommandIsDone() + suite.thenResultShouldBeError("main[1]: nested[0]: fail") +} + +func (suite *TransactionBuilderSuite) givenRegisteredTransaction(modifier ...cmd.TransactionModifier) { + err := suite.builder.Register(modifier...) + require.Nil(suite.T(), err, "no error expected registering transaction") +} + +func (suite *TransactionBuilderSuite) whenCommandIsDone() { + suite.err = suite.command.Do(nil) +} + +func (suite *TransactionBuilderSuite) whenCommandIsUndone() { + suite.err = suite.command.Undo(nil) +} + +func (suite *TransactionBuilderSuite) thenResultShouldBe(expected []string) { + assert.Equal(suite.T(), expected, suite.result) +} + +func (suite *TransactionBuilderSuite) thenResultShouldBeError(expected string) { + require.NotNil(suite.T(), suite.err, "no error returned") + var txnErr cmd.TransactionError + isTxnErr := errors.As(suite.err, &txnErr) + require.True(suite.T(), isTxnErr, "error is not a transaction error: %v", suite.err) + assert.Equal(suite.T(), expected, txnErr.Error()) + assert.NotNil(suite.T(), txnErr.Nested, "no nested error set") +} + +func (suite *TransactionBuilderSuite) Queue(command cmd.Command) { + require.Nil(suite.T(), suite.command, "Not expecting a second command") + suite.command = command +} + +func (suite *TransactionBuilderSuite) aTask(text string) cmd.Task { + return func(modder world.Modder) error { + suite.result = append(suite.result, text) + return nil + } +} + +func (suite *TransactionBuilderSuite) aTaskReturning(err error) cmd.Task { + return func(modder world.Modder) error { + return err + } +} diff --git a/modding/hacked/ss1/edit/undoable/doc.go b/modding/hacked/ss1/edit/undoable/doc.go new file mode 100644 index 0000000..df03381 --- /dev/null +++ b/modding/hacked/ss1/edit/undoable/doc.go @@ -0,0 +1,2 @@ +// Package undoable contains edit extensions that provide an undo-mechanism. +package undoable diff --git a/modding/hacked/ss1/resource/BlockProvider.go b/modding/hacked/ss1/resource/BlockProvider.go new file mode 100644 index 0000000..71be4ea --- /dev/null +++ b/modding/hacked/ss1/resource/BlockProvider.go @@ -0,0 +1,15 @@ +package resource + +import "io" + +// BlockProvider are capable of returning block data of a resource. +type BlockProvider interface { + // BlockCount returns the number of available blocks in the resource. + // Simple resources will always have exactly one block. + BlockCount() int + + // Block returns the reader for the identified block. + // Each call returns a new reader instance. + // Data provided by this reader is always uncompressed. + Block(index int) (io.Reader, error) +} diff --git a/modding/hacked/ss1/resource/Blocks.go b/modding/hacked/ss1/resource/Blocks.go new file mode 100644 index 0000000..620c806 --- /dev/null +++ b/modding/hacked/ss1/resource/Blocks.go @@ -0,0 +1,62 @@ +package resource + +import ( + "bytes" + "io" +) + +// Block is one set of bytes stored in a resource. +// The interpretation of the bytes is dependent on the content type of the resource. +type Block []byte + +// Blocks is a list of blocks in memory. +type Blocks struct { + data [][]byte +} + +// BlocksFrom returns a blocks instance from given data. +func BlocksFrom(data [][]byte) Blocks { + return Blocks{data: data} +} + +// BlockCount returns the number of available blocks. +func (blocks Blocks) BlockCount() int { + return len(blocks.data) +} + +// BlockRaw returns the raw byte slice stored in the identified block. +func (blocks Blocks) BlockRaw(index int) ([]byte, error) { + available := len(blocks.data) + if (index < 0) || (index >= available) { + return nil, ErrBlockNotFound(index, available) + } + return blocks.data[index], nil +} + +// Block returns the reader for the identified block. +// Each call returns a new reader instance. +func (blocks Blocks) Block(index int) (io.Reader, error) { + raw, err := blocks.BlockRaw(index) + if err != nil { + return nil, err + } + return bytes.NewBuffer(raw), nil +} + +// Set the data of all the blocks. +func (blocks *Blocks) Set(data [][]byte) { + blocks.data = data +} + +// SetBlock sets the data of the identified block. +func (blocks *Blocks) SetBlock(index int, data []byte) { + if index < 0 { + return + } + if index >= len(blocks.data) { + newData := make([][]byte, index+1) + copy(newData, blocks.data) + blocks.data = newData + } + blocks.data[index] = data +} diff --git a/modding/hacked/ss1/resource/Blocks_test.go b/modding/hacked/ss1/resource/Blocks_test.go new file mode 100644 index 0000000..f4c6532 --- /dev/null +++ b/modding/hacked/ss1/resource/Blocks_test.go @@ -0,0 +1,59 @@ +package resource_test + +import ( + "io/ioutil" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" +) + +func TestBlockCountReturnsZeroForNilInstance(t *testing.T) { + var blocks resource.Blocks + assert.Equal(t, 0, blocks.BlockCount()) +} + +func TestBlockCountReturnsLengthOfArray(t *testing.T) { + blocks := resource.BlocksFrom(make([][]byte, 3)) + assert.Equal(t, 3, blocks.BlockCount()) +} + +func TestBlockReturnsArrayEntries(t *testing.T) { + blocks := resource.BlocksFrom([][]byte{{0x01}, {0x02, 0x03}}) + verifyBlock := func(index int) { + reader, err := blocks.Block(index) + assert.Nil(t, err) + assert.NotNil(t, reader) + } + verifyBlock(0) + verifyBlock(1) +} + +func TestBlockReturnsErrorForInvalidIndex(t *testing.T) { + blocks := resource.BlocksFrom([][]byte{{0x01}, {0x02, 0x03}}) + verifyError := func(index int) { + _, err := blocks.Block(index) + assert.NotNil(t, err, "Error expected for index ", index) + } + verifyError(-1) + verifyError(2) + verifyError(3) +} + +func TestBlockSetting(t *testing.T) { + var blocks resource.Blocks + + blocks.Set(make([][]byte, 3)) + assert.Equal(t, 3, blocks.BlockCount(), "block count should have been set") + + blocks.SetBlock(1, []byte{0x01, 0x02}) + block, err := blocks.Block(1) + assert.Nil(t, err, "block should be known") + storedData, err := ioutil.ReadAll(block) + assert.Nil(t, err, "data should be read") + assert.Equal(t, []byte{0x01, 0x02}, storedData, "stored data mismatch") + + blocks.SetBlock(5, []byte{0xAA}) + assert.Equal(t, 6, blocks.BlockCount(), "block count should have been updated") +} diff --git a/modding/hacked/ss1/resource/ChangeNotifier.go b/modding/hacked/ss1/resource/ChangeNotifier.go new file mode 100644 index 0000000..809660a --- /dev/null +++ b/modding/hacked/ss1/resource/ChangeNotifier.go @@ -0,0 +1,131 @@ +package resource + +import ( + "bytes" + "crypto/md5" // nolint: gas + "encoding/binary" + "io" + "sync" +) + +type resourceHash []byte +type resourceHashes map[ID]resourceHash +type resourceHashSnapshot map[Language]resourceHashes + +// ModificationCallback is a callback function to notify a change in resources. +type ModificationCallback func(modifiedIDs []ID, failedIDs []ID) + +// Localizer provides selectors to resources for specific languages. +type Localizer interface { + LocalizedResources(lang Language) Selector +} + +// ChangeNotifier is a utility that assists in detecting changes in modifying resources. +// A callback is called for any resource identifier that refers to resources that are different after a modification. +// +// Use this utility in combination to resource lists where resources can be overwritten by other entries. +// Changes in order, or content, affects how a resource is resolved. +type ChangeNotifier struct { + Localizer Localizer + Callback ModificationCallback +} + +// ModifyAndNotify must be called with a range of affected IDs that will change during the call of the modifier. +// A hash snapshot is taken before and after the modifier, considering the affected IDs. +// Any change is then reported to the callback, listing all IDs that have different hashes. +// +// Hashing the resources considers all languages, as well as the meta-information of the resources. +func (notifier ChangeNotifier) ModifyAndNotify(modifier func(), affectedIDs ...[]ID) { + var flattenedIDs []ID + for _, idList := range affectedIDs { + flattenedIDs = append(flattenedIDs, idList...) + } + oldHashes, _ := notifier.hashAll(flattenedIDs) + modifier() + newHashes, failedIDs := notifier.hashAll(flattenedIDs) + modifiedResourceIDs := notifier.modifiedIDs(oldHashes, newHashes) + notifier.Callback(modifiedResourceIDs, failedIDs) +} + +func (notifier ChangeNotifier) hashAll(ids []ID) (hashes resourceHashSnapshot, failedIDs []ID) { + var failedMap IDMarkerMap + hashes = make(resourceHashSnapshot) + var selectMutex sync.Mutex + var mapMutex sync.Mutex + for _, lang := range Languages() { + hashByResourceID := make(resourceHashes) + hashes[lang] = hashByResourceID + selector := notifier.Localizer.LocalizedResources(lang) + selectorFunc := func(id ID) (View, error) { + selectMutex.Lock() + defer selectMutex.Unlock() + return selector.Select(id) + } + var wg sync.WaitGroup + + for _, id := range ids { + wg.Add(1) + go func(id ID) { + defer wg.Done() + hash, hashErr := notifier.hashResource(selectorFunc, id) + mapMutex.Lock() + defer mapMutex.Unlock() + if hashErr == nil { + hashByResourceID[id] = hash + } else { + failedMap.Add(id) + } + }(id) + } + wg.Wait() + } + failedIDs = failedMap.ToList() + return +} + +func (notifier ChangeNotifier) hashResource(selector func(ID) (View, error), id ID) (resourceHash, error) { + view, viewErr := selector(id) + if viewErr != nil { + return nil, viewErr + } + hasher := md5.New() // nolint: gas + blocks := view.BlockCount() + for index := 0; index < blocks; index++ { + blockReader, blockErr := view.Block(index) + if blockErr != nil { + return nil, blockErr + } + written, hashErr := io.Copy(hasher, blockReader) + if hashErr != nil { + return nil, hashErr + } + binary.Write(hasher, binary.LittleEndian, &written) // nolint: errcheck + } + binary.Write(hasher, binary.LittleEndian, int64(blocks)) // nolint: errcheck + binary.Write(hasher, binary.LittleEndian, view.Compound()) // nolint: errcheck + binary.Write(hasher, binary.LittleEndian, view.ContentType()) // nolint: errcheck + binary.Write(hasher, binary.LittleEndian, view.Compressed()) // nolint: errcheck + + return hasher.Sum(nil), nil +} + +func (notifier ChangeNotifier) modifiedIDs(oldHashes resourceHashSnapshot, newHashes resourceHashSnapshot) []ID { + var modifiedIDs IDMarkerMap + for _, lang := range Languages() { + oldHashesByResourceID := oldHashes[lang] + newHashesByResourceID := newHashes[lang] + + for newID, newHash := range newHashesByResourceID { + oldHash, oldExisting := oldHashesByResourceID[newID] + if !oldExisting || !bytes.Equal(newHash, oldHash) { + modifiedIDs.Add(newID) + } + } + for oldID := range oldHashesByResourceID { + if _, newExisting := newHashesByResourceID[oldID]; !newExisting { + modifiedIDs.Add(oldID) + } + } + } + return modifiedIDs.ToList() +} diff --git a/modding/hacked/ss1/resource/ChangeNotifier_test.go b/modding/hacked/ss1/resource/ChangeNotifier_test.go new file mode 100644 index 0000000..44cd3fc --- /dev/null +++ b/modding/hacked/ss1/resource/ChangeNotifier_test.go @@ -0,0 +1,136 @@ +package resource_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type ChangeNotifierSuite struct { + suite.Suite + + lastModifiedIDs []resource.ID + lastFailedIDs []resource.ID + + localizedResources resource.LocalizedResourcesList + + instance resource.ChangeNotifier +} + +func TestResourceChangeNotifierSuite(t *testing.T) { + suite.Run(t, new(ChangeNotifierSuite)) +} + +func (suite *ChangeNotifierSuite) SetupTest() { + suite.lastModifiedIDs = nil + suite.lastFailedIDs = nil + + suite.instance.Localizer = suite + suite.instance.Callback = suite.onResourcesModified +} + +func (suite *ChangeNotifierSuite) TestNoIDsAreNotifiedIfDataNotChanged() { + suite.givenListIsMadeOf( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, []byte{0x01}))) + suite.whenResourcesAreModified(func() {}, 0x1000) + suite.thenModifiedIDsShouldBe() +} + +func (suite *ChangeNotifierSuite) TestIDsAreNotifiedOfNewData() { + suite.givenListIsMadeOf( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, []byte{0x01}))) + suite.whenResourcesAreModified(func() { + suite.extendingListWith( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x2000, []byte{0x01}))) + }, 0x2000) + suite.thenModifiedIDsShouldBe(0x2000) +} + +func (suite *ChangeNotifierSuite) TestIDsAreNotifiedOfRemovedData() { + suite.givenListIsMadeOf( + suite.someLocalizedResources(resource.LangGerman, + suite.storing(0x1000, []byte{0x01}))) + suite.whenResourcesAreModified(func() { + suite.listIsMadeOf() + }, 0x1000) + suite.thenModifiedIDsShouldBe(0x1000) +} + +func (suite *ChangeNotifierSuite) TestIDsAreNotifiedOfChangedData() { + suite.givenListIsMadeOf( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x1000, []byte{0x01}))) + suite.whenResourcesAreModified(func() { + suite.listIsMadeOf( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x1000, []byte{0x02}))) + }, 0x1000) + suite.thenModifiedIDsShouldBe(0x1000) +} + +func (suite *ChangeNotifierSuite) givenListIsMadeOf(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *ChangeNotifierSuite) extendingListWith(resources ...resource.LocalizedResources) { + suite.localizedResources = append(suite.localizedResources, resources...) +} + +func (suite *ChangeNotifierSuite) listIsMadeOf(resources ...resource.LocalizedResources) { + suite.localizedResources = resources +} + +func (suite *ChangeNotifierSuite) whenResourcesAreModified(modifier func(), affectedIDs ...int) { + convertedIDs := make([]resource.ID, len(affectedIDs)) + for i, id := range affectedIDs { + convertedIDs[i] = resource.ID(id) + } + suite.instance.ModifyAndNotify(modifier, convertedIDs) +} + +func (suite *ChangeNotifierSuite) thenModifiedIDsShouldBe(expected ...int) { + convertedIDs := make([]resource.ID, len(expected)) + for i, id := range expected { + convertedIDs[i] = resource.ID(id) + } + + assert.Equal(suite.T(), convertedIDs, suite.lastModifiedIDs, "IDs don't match") +} + +func (suite *ChangeNotifierSuite) someLocalizedResources(lang resource.Language, modifiers ...func(*resource.Store)) resource.LocalizedResources { + var store resource.Store + for _, modifier := range modifiers { + modifier(&store) + } + return resource.LocalizedResources{ + ID: "unnamed", + Language: lang, + Viewer: store, + } +} + +func (suite *ChangeNotifierSuite) storing(id int, data ...[]byte) func(*resource.Store) { + return func(store *resource.Store) { + _ = store.Put(resource.ID(id), resource.Resource{ + Blocks: resource.BlocksFrom(data), + }) + } +} + +func (suite *ChangeNotifierSuite) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + From: suite.localizedResources, + Lang: lang, + } +} + +func (suite *ChangeNotifierSuite) onResourcesModified(modifiedIDs []resource.ID, failedIDs []resource.ID) { + suite.lastModifiedIDs = modifiedIDs + suite.lastFailedIDs = failedIDs +} diff --git a/modding/hacked/ss1/resource/ContentType.go b/modding/hacked/ss1/resource/ContentType.go new file mode 100644 index 0000000..df5e508 --- /dev/null +++ b/modding/hacked/ss1/resource/ContentType.go @@ -0,0 +1,48 @@ +package resource + +import "fmt" + +// ContentType identifies how resource data shall be interpreted. +type ContentType byte + +var contentTypeNames = map[ContentType]string{ + Palette: "Palette", + Text: "Text", + Bitmap: "Bitmap", + Font: "Font", + Animation: "Animation", + Sound: "Sound", + Geometry: "Geometry", + Movie: "Movie", + Archive: "Archive", +} + +// String returns the textual representation of the type. +func (t ContentType) String() string { + s, existing := contentTypeNames[t] + if existing { + return s + } + return fmt.Sprintf("Unknown%02X", int(t)) +} + +const ( + // Palette refers to color tables. + Palette = ContentType(0x00) + // Text refers to texts. + Text = ContentType(0x01) + // Bitmap refers to images. + Bitmap = ContentType(0x02) + // Font refers to font descriptions. + Font = ContentType(0x03) + // Animation refers to graphical animations. + Animation = ContentType(0x04) + // Sound refers to audio samples. + Sound = ContentType(0x07) + // Geometry refers to 3D models. + Geometry = ContentType(0x0F) + // Movie refers to audio logs and cutscenes. + Movie = ContentType(0x11) + // Archive refers to archive data. + Archive = ContentType(0x30) +) diff --git a/modding/hacked/ss1/resource/ContentType_test.go b/modding/hacked/ss1/resource/ContentType_test.go new file mode 100644 index 0000000..7f86107 --- /dev/null +++ b/modding/hacked/ss1/resource/ContentType_test.go @@ -0,0 +1,33 @@ +package resource_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +func TestContentTypeString(t *testing.T) { + tt := []struct { + contentType resource.ContentType + expected string + }{ + {resource.Palette, "Palette"}, + {resource.Text, "Text"}, + {resource.Bitmap, "Bitmap"}, + {resource.Font, "Font"}, + {resource.Animation, "Animation"}, + {resource.Sound, "Sound"}, + {resource.Geometry, "Geometry"}, + {resource.Movie, "Movie"}, + {resource.Archive, "Archive"}, + {resource.ContentType(254), "UnknownFE"}, + } + + for _, tc := range tt { + result := tc.contentType.String() + assert.Equal(t, tc.expected, result, fmt.Sprintf("Failed for 0x%02X", int(tc.contentType))) + } +} diff --git a/modding/hacked/ss1/resource/Errors.go b/modding/hacked/ss1/resource/Errors.go new file mode 100644 index 0000000..fa0a566 --- /dev/null +++ b/modding/hacked/ss1/resource/Errors.go @@ -0,0 +1,51 @@ +package resource + +import "fmt" + +// NotFoundError indicates the unknown ID. +type NotFoundError struct { + ID ID +} + +// Error implements the error interface. +func (err NotFoundError) Error() string { + return fmt.Sprintf("resource with ID %v does not exist", err.ID) +} + +// ErrNotFound returns an error specifying the given ID doesn't +// have an associated resource. +func ErrNotFound(id ID) error { + return NotFoundError{ID: id} +} + +// BlockNotFoundError indicates the unknown block index. +type BlockNotFoundError struct { + Index int + Available int +} + +// Error implements the error interface. +func (err BlockNotFoundError) Error() string { + return fmt.Sprintf("block index wrong: %v/%v", err.Index, err.Available) +} + +// ErrBlockNotFound returns an error specifying the given index does not identify a valid block. +func ErrBlockNotFound(index, available int) error { + return BlockNotFoundError{Index: index, Available: available} +} + +// WrongTypeError indicates a resource of unexpected type. +type WrongTypeError struct { + Key Key + Expected ContentType +} + +// Error implements the error interface. +func (err WrongTypeError) Error() string { + return fmt.Sprintf("resource %v does not have expected type %v", err.Key, err.Expected) +} + +// ErrWrongType returns an error specifying a resource was of unexpected type. +func ErrWrongType(key Key, expected ContentType) error { + return WrongTypeError{Key: key, Expected: expected} +} diff --git a/modding/hacked/ss1/resource/Identifier.go b/modding/hacked/ss1/resource/Identifier.go new file mode 100644 index 0000000..c80a4ba --- /dev/null +++ b/modding/hacked/ss1/resource/Identifier.go @@ -0,0 +1,47 @@ +package resource + +import ( + "fmt" + "sort" +) + +// ID represents an integer key of resources. +type ID uint16 + +// Value returns the numerical value of the identifier. +func (id ID) Value() uint16 { + return uint16(id) +} + +// Plus adds the given offset and returns the resulting ID. +func (id ID) Plus(offset int) ID { + return ID(int(id) + offset) +} + +// String returns the textual representation. +func (id ID) String() string { + return fmt.Sprintf("%04X", uint16(id)) +} + +// IDMarkerMap is used to collect IDs. +type IDMarkerMap struct { + ids map[ID]struct{} +} + +// Add adds the given ID to the map. +func (marker *IDMarkerMap) Add(id ID) { + if marker.ids == nil { + marker.ids = make(map[ID]struct{}) + } + marker.ids[id] = struct{}{} +} + +// ToList converts the map to a de-duplicated list. +func (marker IDMarkerMap) ToList() []ID { + result := make([]ID, 0, len(marker.ids)) + for id := range marker.ids { + result = append(result, id) + } + sort.Slice(result, func(a, b int) bool { return result[a] < result[b] }) + return result +} diff --git a/modding/hacked/ss1/resource/Identifier_test.go b/modding/hacked/ss1/resource/Identifier_test.go new file mode 100644 index 0000000..a80ff63 --- /dev/null +++ b/modding/hacked/ss1/resource/Identifier_test.go @@ -0,0 +1,39 @@ +package resource_test + +import ( + "fmt" + "math" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" +) + +func TestIDValueReturnsOwnValue(t *testing.T) { + assert.Equal(t, uint16(0), resource.ID(0).Value()) + assert.Equal(t, uint16(123), resource.ID(123).Value()) + assert.Equal(t, uint16(math.MaxUint16), resource.ID(math.MaxUint16).Value(), "maximum of uint16 should be supported") +} + +func TestIDImplementsStringer(t *testing.T) { + assert.Equal(t, "0FA0", fmt.Sprintf("%v", resource.ID(0x0FA0))) +} + +func TestIDPlusReturnsOffsetValue(t *testing.T) { + tt := []struct { + base resource.ID + offset int + expected resource.ID + }{ + {0, 0, 0}, + {10, 0, 10}, + {10, 2, 12}, + {10, -2, 8}, + } + + for _, tc := range tt { + result := tc.base.Plus(tc.offset) + assert.Equal(t, tc.expected, result, "ID mismatch") + } +} diff --git a/modding/hacked/ss1/resource/Key.go b/modding/hacked/ss1/resource/Key.go new file mode 100644 index 0000000..bb589bb --- /dev/null +++ b/modding/hacked/ss1/resource/Key.go @@ -0,0 +1,20 @@ +package resource + +// Key identifies a particular resource out of a combination of a resource ID, a language, and a block index. +// +// Keys are used for retrieving particular content that is stored in resources. +// Not all resources contain only one atomic entity. +type Key struct { + // ID identifies the resource. + ID ID + // Lang localizes the resource, should the actual content be localized. + Lang Language + // Index specifies the exact entry within a resource. + Index int +} + +// KeyOf is a factory function for creating a Key instance. +// It is done to avoid getting warnings when trying to do a similar construction via Key{id, lang, index}. +func KeyOf(id ID, lang Language, index int) Key { + return Key{ID: id, Lang: lang, Index: index} +} diff --git a/modding/hacked/ss1/resource/Key_test.go b/modding/hacked/ss1/resource/Key_test.go new file mode 100644 index 0000000..34ba507 --- /dev/null +++ b/modding/hacked/ss1/resource/Key_test.go @@ -0,0 +1,14 @@ +package resource_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" +) + +func TestKeyOfReturnsKey(t *testing.T) { + assert.Equal(t, resource.Key{ID: 0x1000, Lang: resource.LangGerman, Index: 123}, resource.KeyOf(0x1000, resource.LangGerman, 123)) + assert.Equal(t, resource.Key{ID: 0xA000, Lang: resource.LangAny, Index: 0}, resource.KeyOf(0xA000, resource.LangAny, 0)) +} diff --git a/modding/hacked/ss1/resource/Language.go b/modding/hacked/ss1/resource/Language.go new file mode 100644 index 0000000..2468342 --- /dev/null +++ b/modding/hacked/ss1/resource/Language.go @@ -0,0 +1,46 @@ +package resource + +import "fmt" + +// Language defines the human language of a resource. +type Language byte + +const ( + // LangAny identifies language agnostic resources. + LangAny Language = 0xFF + // LangDefault identifies the default language, typically English - unless modded. + LangDefault Language = 0 + // LangFrench identifies the French language. + LangFrench Language = 1 + // LangGerman identifies the German language. + LangGerman Language = 2 + + // LanguageCount specifies how many languages are supported. + LanguageCount = 3 +) + +func (lang Language) String() string { + switch lang { + case LangAny: + return "Any" + case LangDefault: + return "Default" + case LangFrench: + return "French" + case LangGerman: + return "German" + default: + return fmt.Sprintf("Unknown%02X", int(lang)) + } +} + +// Languages returns a slice of all human languages. Does not include "Any" selector. +func Languages() []Language { + return []Language{LangDefault, LangFrench, LangGerman} +} + +// Includes returns true if the language includes the provided one. +// This is not symmetrical. While "Any" includes "German", "German" does not include "Any". +func (lang Language) Includes(other Language) bool { + return (lang == LangAny) || (lang == other) +} diff --git a/modding/hacked/ss1/resource/Language_test.go b/modding/hacked/ss1/resource/Language_test.go new file mode 100644 index 0000000..c987195 --- /dev/null +++ b/modding/hacked/ss1/resource/Language_test.go @@ -0,0 +1,58 @@ +package resource_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +func TestLanguageIncludes(t *testing.T) { + tt := []struct { + primary resource.Language + secondary resource.Language + expected bool + }{ + {resource.LangAny, resource.LangAny, true}, + {resource.LangAny, resource.LangGerman, true}, + {resource.LangFrench, resource.LangAny, false}, + {resource.LangGerman, resource.LangFrench, false}, + {resource.LangFrench, resource.LangFrench, true}, + } + + for _, tc := range tt { + td := tc + t.Run(fmt.Sprintf("Expecting %v including %v should be %v", td.primary, td.secondary, td.expected), func(t *testing.T) { + result := td.primary.Includes(td.secondary) + assert.Equal(t, td.expected, result) + }) + } +} + +func TestLanguageString(t *testing.T) { + tt := []struct { + lang resource.Language + expected string + }{ + {resource.LangAny, "Any"}, + {resource.LangDefault, "Default"}, + {resource.LangFrench, "French"}, + {resource.LangGerman, "German"}, + {resource.Language(0x40), "Unknown40"}, + } + + for _, tc := range tt { + td := tc + t.Run(td.expected, func(t *testing.T) { + result := td.lang.String() + assert.Equal(t, td.expected, result) + }) + } +} + +func TestLanguages(t *testing.T) { + result := resource.Languages() + assert.Equal(t, 3, len(result)) +} diff --git a/modding/hacked/ss1/resource/List.go b/modding/hacked/ss1/resource/List.go new file mode 100644 index 0000000..05c9831 --- /dev/null +++ b/modding/hacked/ss1/resource/List.go @@ -0,0 +1,24 @@ +package resource + +// List is a helper type for slices of resources. +// Although the length of the list is immutable, the content and the fields are not. +// Meaning, modifying the cells of one list may affect the cells of others. +type List []View + +// With returns a new list with the provided resource appended at the end. +func (list List) With(res View) List { + return append(list, res) +} + +// Joined returns a new list which is the combination of this list and the provided one. +func (list List) Joined(other List) List { + otherLen := len(other) + if otherLen == 0 { + return list + } + listLen := len(list) + newList := make([]View, listLen+otherLen) + copy(newList[:listLen], list) + copy(newList[listLen:], other) + return newList +} diff --git a/modding/hacked/ss1/resource/ListMerger.go b/modding/hacked/ss1/resource/ListMerger.go new file mode 100644 index 0000000..2b1d424 --- /dev/null +++ b/modding/hacked/ss1/resource/ListMerger.go @@ -0,0 +1,62 @@ +package resource + +import ( + "bytes" + "io" +) + +type listMerger struct { + list List +} + +// Compound indicates whether the resource is a compound one, which can contain zero, one, or more blocks. +// Simple resources always contain exactly one block. +func (view listMerger) Compound() bool { + return view.list[0].Compound() +} + +// ContentType specifies how the data in the resource shall be interpreted. +func (view listMerger) ContentType() ContentType { + return view.list[0].ContentType() +} + +// Compressed indicates whether the data should be compressed when serialized. +func (view listMerger) Compressed() bool { + return view.list[0].Compressed() +} + +// BlockCount returns the maximum count of all resources. +func (view listMerger) BlockCount() (max int) { + for _, layer := range view.list { + count := layer.BlockCount() + if max < count { + max = count + } + } + return +} + +// Block returns a reader for the identified block in the resource. +// The view returns the block from the first resource that has a non-empty block. +// The resources are checked from last-to-first. +func (view listMerger) Block(index int) (reader io.Reader, err error) { + blockCount := view.BlockCount() + if (index < 0) || (index >= blockCount) { + return nil, ErrBlockNotFound(index, blockCount) + } + + for layer := len(view.list) - 1; (layer >= 0) && (reader == nil); layer-- { + tempReader, tempErr := view.list[layer].Block(index) + if tempErr == nil { + var buf [1]byte + read, _ := tempReader.Read(buf[:]) + if read == 1 { + reader, err = view.list[layer].Block(index) + } + } + } + if (reader == nil) && (err == nil) { + reader = bytes.NewBuffer(nil) + } + return +} diff --git a/modding/hacked/ss1/resource/List_test.go b/modding/hacked/ss1/resource/List_test.go new file mode 100644 index 0000000..00cfc8d --- /dev/null +++ b/modding/hacked/ss1/resource/List_test.go @@ -0,0 +1,52 @@ +package resource_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" +) + +func someResource(key byte) resource.View { + return resource.Resource{ + Blocks: resource.BlocksFrom([][]byte{{key}}), + } +} + +func TestListWith(t *testing.T) { + t.Run("nil list", func(t *testing.T) { + var base resource.List + result := base.With(someResource(0x01)) + assert.Equal(t, resource.List([]resource.View{someResource(0x01)}), result) + }) + + t.Run("append", func(t *testing.T) { + base := resource.List([]resource.View{someResource(0x01)}) + result := base.With(someResource(0x03)) + assert.Equal(t, resource.List([]resource.View{someResource(0x01), someResource(0x03)}), result) + }) + + t.Run("immutable source", func(t *testing.T) { + raw := make([]resource.View, 1, 2) + raw[0] = someResource(0x02) + base := resource.List(raw) + result := base.With(someResource(0x04)) + assert.Equal(t, 2, len(result)) + assert.Equal(t, resource.List([]resource.View{someResource(0x02)}), base) + }) +} + +func TestListJoined(t *testing.T) { + var empty resource.List + t.Run("two lists", func(t *testing.T) { + base := empty.With(someResource(0x01)).With(someResource(0x02)) + other := empty.With(someResource(0x03)).With(someResource(0x04)) + result := base.Joined(other) + assert.Equal(t, resource.List([]resource.View{someResource(0x01), someResource(0x02), someResource(0x03), someResource(0x04)}), result) + }) + t.Run("nil with nil is nil", func(t *testing.T) { + result := empty.Joined(empty) + assert.Nil(t, result) + }) +} diff --git a/modding/hacked/ss1/resource/LocalizedResources.go b/modding/hacked/ss1/resource/LocalizedResources.go new file mode 100644 index 0000000..c453886 --- /dev/null +++ b/modding/hacked/ss1/resource/LocalizedResources.go @@ -0,0 +1,11 @@ +package resource + +// LocalizedResources associates a language with a resource viewer under a specific identifier. +type LocalizedResources struct { + // ID is the identifier of the viewer. This could be a filename for instance. + ID string + // Language specifies for which language the viewer has resources. + Language Language + // Viewer is the actual container of the resources. + Viewer Viewer +} diff --git a/modding/hacked/ss1/resource/LocalizedResourcesList.go b/modding/hacked/ss1/resource/LocalizedResourcesList.go new file mode 100644 index 0000000..60c5158 --- /dev/null +++ b/modding/hacked/ss1/resource/LocalizedResourcesList.go @@ -0,0 +1,18 @@ +package resource + +// LocalizedResourcesList is a collection of localized resources. +// it exists to provide a typical implementation of a Selector. +type LocalizedResourcesList []LocalizedResources + +// Filter returns all resources that match the given parameters. +func (list LocalizedResourcesList) Filter(lang Language, id ID) List { + var result List + for _, localized := range list { + if localized.Language.Includes(lang) { + if res, err := localized.Viewer.View(id); err == nil { + result = result.With(res) + } + } + } + return result +} diff --git a/modding/hacked/ss1/resource/Resource.go b/modding/hacked/ss1/resource/Resource.go new file mode 100644 index 0000000..d61027e --- /dev/null +++ b/modding/hacked/ss1/resource/Resource.go @@ -0,0 +1,38 @@ +package resource + +// Properties describe the meta information about a resource. +type Properties struct { + // Compound tells whether the resource should be serialized with a directory. + // Compound resources can have zero, one, or more blocks. + // Simple resources always have exactly one block. + Compound bool + + // ContentType describes how the block data shall be interpreted. + ContentType ContentType + + // Compressed tells whether the data shall be serialized in compressed form. + Compressed bool +} + +// Resource provides meta information as well as access to its contained blocks. +type Resource struct { + Properties + Blocks +} + +// Compound tells whether the resource should be serialized with a directory. +// Compound resources can have zero, one, or more blocks. +// Simple resources always have exactly one block. +func (res Resource) Compound() bool { + return res.Properties.Compound +} + +// ContentType describes how the block data shall be interpreted. +func (res Resource) ContentType() ContentType { + return res.Properties.ContentType +} + +// Compressed tells whether the data shall be serialized in compressed form. +func (res Resource) Compressed() bool { + return res.Properties.Compressed +} diff --git a/modding/hacked/ss1/resource/Resource_test.go b/modding/hacked/ss1/resource/Resource_test.go new file mode 100644 index 0000000..9dfad00 --- /dev/null +++ b/modding/hacked/ss1/resource/Resource_test.go @@ -0,0 +1,54 @@ +package resource_test + +import ( + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +func verifyBlockContent(t *testing.T, provider resource.BlockProvider, index int, expected []byte) { + t.Helper() + reader, err := provider.Block(index) + assert.Nil(t, err, "No error expected for index %d", index) + assert.NotNil(t, reader, "Reader expected for index %d", index) + if reader != nil { + data, dataErr := ioutil.ReadAll(reader) + assert.Nil(t, dataErr, "No error expected reading data from index %d", index) + assert.Equal(t, expected, data, "Proper data expected from index %d", index) + } +} + +func verifyBlockError(t *testing.T, provider resource.BlockProvider, index int) { + t.Helper() + _, err := provider.Block(index) + assert.NotNil(t, err, "Error expected for index %d", index) +} + +func TestResourceRefersToBlockProviderByDefault(t *testing.T) { + res := &resource.Resource{Blocks: resource.BlocksFrom([][]byte{{0x01}, {0x02, 0x02}})} + + assert.Equal(t, 2, res.BlockCount()) + verifyBlockContent(t, res, 0, []byte{0x01}) + verifyBlockContent(t, res, 1, []byte{0x02, 0x02}) +} + +func TestResourceBlockReturnsErrorOnInvalidIndex(t *testing.T) { + res := &resource.Resource{Blocks: resource.BlocksFrom(nil)} + + verifyBlockError(t, res, -1) + verifyBlockError(t, res, 0) + verifyBlockError(t, res, 1) + verifyBlockError(t, res, 2) +} + +func TestResourceBlockReturnsErrorForDefaultObject(t *testing.T) { + var res resource.Resource + + assert.Equal(t, 0, res.BlockCount()) + verifyBlockError(t, res, -1) + verifyBlockError(t, res, 0) + verifyBlockError(t, res, 1) +} diff --git a/modding/hacked/ss1/resource/Selector.go b/modding/hacked/ss1/resource/Selector.go new file mode 100644 index 0000000..94332bb --- /dev/null +++ b/modding/hacked/ss1/resource/Selector.go @@ -0,0 +1,34 @@ +package resource + +// Filter filters for language and id to produce a list of matching resources. +type Filter interface { + Filter(lang Language, id ID) List +} + +// Selector provides a merged view of resources according to a language. +type Selector struct { + // Lang specifies the language to filter by. + Lang Language + + // From specifies from where the resources shall be taken. + From Filter + + // As defines how the found resources should be viewed in case more than one matches. + // By default, the last resource will be used. + As ViewStrategy +} + +// Select provides a collected view on one resource. +func (merger Selector) Select(id ID) (view View, err error) { + list := merger.From.Filter(merger.Lang, id) + if len(list) == 0 { + return nil, ErrNotFound(id) + } + if (merger.As == nil) || !merger.As.IsCompoundList(id) { + view = list[len(list)-1] + } else { + view = &listMerger{list: list} + } + + return view, nil +} diff --git a/modding/hacked/ss1/resource/Selector_test.go b/modding/hacked/ss1/resource/Selector_test.go new file mode 100644 index 0000000..fb731a2 --- /dev/null +++ b/modding/hacked/ss1/resource/Selector_test.go @@ -0,0 +1,225 @@ +package resource_test + +import ( + "fmt" + "io/ioutil" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ResourceSelectorSuite struct { + suite.Suite + + resources []resource.View + view resource.View + + isCompoundList bool + viewStrategy resource.ViewStrategy +} + +func TestResourceSelectorSuite(t *testing.T) { + suite.Run(t, new(ResourceSelectorSuite)) +} + +func (suite *ResourceSelectorSuite) SetupTest() { + suite.resources = nil + suite.view = nil + suite.isCompoundList = false + suite.viewStrategy = nil +} + +func (suite *ResourceSelectorSuite) TestBlockReturnsData() { + suite.givenResource(resource.BlocksFrom([][]byte{{0x01}})) + suite.whenInstanceIsCreated() + suite.thenResourceBlockShouldBe(0, []byte{0x01}) +} + +func (suite *ResourceSelectorSuite) TestBlockReturnsDataFromLastEntryByDefault() { + suite.givenSpecificResource(func(res *resource.Resource) { + res.Blocks = resource.BlocksFrom([][]byte{{0xAA}}) + }) + suite.givenSpecificResource(func(res *resource.Resource) { + res.Blocks = resource.BlocksFrom([][]byte{{0xBB}}) + }) + suite.whenInstanceIsCreated() + suite.thenResourceBlockShouldBe(0, []byte{0xBB}) +} + +func (suite *ResourceSelectorSuite) TestBlockReturnsDataFromLastEntryForCompoundNonListResources() { + suite.givenViewStrategyIsSet() + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Blocks = resource.BlocksFrom([][]byte{{0xAA}}) + }) + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Blocks = resource.BlocksFrom([][]byte{{0xBB}, {0xCC}}) + }) + suite.whenInstanceIsCreated() + suite.thenResourceBlockCountShouldBe(2) + suite.thenResourceBlockShouldBe(0, []byte{0xBB}) + suite.thenResourceBlockShouldBe(1, []byte{0xCC}) +} + +func (suite *ResourceSelectorSuite) TestBlockReturnsDataFromLastNonEmptyEntryIfACompoundList() { + suite.givenResourceIsACompoundList() + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Blocks = resource.BlocksFrom([][]byte{{0xAA}}) + }) + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Blocks = resource.BlocksFrom([][]byte{{}}) + }) + suite.whenInstanceIsCreated() + suite.thenResourceBlockShouldBe(0, []byte{0xAA}) +} + +func (suite *ResourceSelectorSuite) TestBlockCountOfACompoundListReturnsHighestCount() { + suite.givenResourceIsACompoundList() + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Blocks = resource.BlocksFrom([][]byte{{}, {}, {0x11}}) + }) + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Blocks = resource.BlocksFrom([][]byte{{}, {0x22}}) + }) + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Blocks = resource.BlocksFrom([][]byte{{}, {}, {}, {0x33}}) + }) + suite.whenInstanceIsCreated() + suite.thenResourceBlockCountShouldBe(4) +} + +func (suite *ResourceSelectorSuite) TestMetaOfCompoundListIsThatOfFirst() { + suite.givenResourceIsACompoundList() + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Properties.Compressed = true + res.Properties.ContentType = resource.Movie + res.Blocks = resource.BlocksFrom([][]byte{{}, {}, {0x11}}) + }) + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = false + res.Properties.Compressed = false + res.Properties.ContentType = resource.Text + res.Blocks = resource.BlocksFrom([][]byte{{}, {0x22}}) + }) + suite.whenInstanceIsCreated() + suite.thenResourceShouldHaveMeta(true, resource.Movie, true) +} + +func (suite *ResourceSelectorSuite) TestMetaOfNonCompoundListIsThatOfLast() { + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = true + res.Properties.Compressed = true + res.Properties.ContentType = resource.Movie + res.Blocks = resource.BlocksFrom([][]byte{{}, {}, {0x11}}) + }) + suite.givenSpecificResource(func(res *resource.Resource) { + res.Properties.Compound = false + res.Properties.Compressed = false + res.Properties.ContentType = resource.Text + res.Blocks = resource.BlocksFrom([][]byte{{}, {0x22}}) + }) + suite.whenInstanceIsCreated() + suite.thenResourceShouldHaveMeta(false, resource.Text, false) +} + +func (suite *ResourceSelectorSuite) TestBlockReturnsErrorForIndexOutOfRange() { + suite.givenSpecificResource(func(res *resource.Resource) { + res.Blocks = resource.BlocksFrom(nil) + }) + suite.whenInstanceIsCreated() + suite.thenBlockShouldReturnErrorFor(0) + suite.thenBlockShouldReturnErrorFor(-1) +} + +func (suite *ResourceSelectorSuite) TestBlockReturnsEmptyReaderIfAllLayersAreEmpty() { + suite.givenSpecificResource(func(res *resource.Resource) { + res.Blocks = resource.BlocksFrom([][]byte{{}}) + }) + suite.whenInstanceIsCreated() + suite.thenBlockShouldReturnFor(0, []byte{}) +} + +func (suite *ResourceSelectorSuite) givenResourceIsACompoundList() { + suite.isCompoundList = true + suite.viewStrategy = suite +} + +func (suite *ResourceSelectorSuite) givenViewStrategyIsSet() { + suite.viewStrategy = suite +} + +func (suite *ResourceSelectorSuite) givenResource(blocks resource.Blocks) { + suite.givenSpecificResource(func(res *resource.Resource) { + res.Blocks = blocks + }) +} + +func (suite *ResourceSelectorSuite) givenSpecificResource(modifier func(*resource.Resource)) { + res := resource.Resource{} + modifier(&res) + suite.resources = append(suite.resources, res) +} + +func (suite *ResourceSelectorSuite) whenInstanceIsCreated() { + selector := resource.Selector{ + Lang: resource.LangAny, + From: suite, + As: suite.viewStrategy, + } + + var err error + suite.view, err = selector.Select(resource.ID(1000)) + require.Nil(suite.T(), err, "No error expected creating view for %v", 1000) +} + +func (suite *ResourceSelectorSuite) thenResourceBlockCountShouldBe(expected int) { + assert.Equal(suite.T(), expected, suite.view.BlockCount(), "Proper block count expected") +} + +func (suite *ResourceSelectorSuite) thenResourceBlockShouldBe(block int, expected []byte) { + reader, err := suite.view.Block(block) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected retrieving block %v", block)) + data, readErr := ioutil.ReadAll(reader) + require.Nil(suite.T(), readErr, fmt.Sprintf("No error expected reading block %v", block)) + assert.Equal(suite.T(), expected, data, "Block data expected") +} + +func (suite *ResourceSelectorSuite) thenResourceShouldHaveMeta(compound bool, contentType resource.ContentType, compressed bool) { + assert.Equal(suite.T(), compound, suite.view.Compound(), "Compound state is wrong") + assert.Equal(suite.T(), contentType, suite.view.ContentType(), "Content type is wrong") + assert.Equal(suite.T(), compressed, suite.view.Compressed(), "Compression is wrong") +} + +func (suite *ResourceSelectorSuite) thenBlockShouldReturnErrorFor(index int) { + _, err := suite.view.Block(index) + assert.NotNil(suite.T(), err, "Error expected") +} + +func (suite *ResourceSelectorSuite) thenBlockShouldReturnFor(index int, expected []byte) { + reader, readerErr := suite.view.Block(index) + require.Nil(suite.T(), readerErr, "No error expected retrieving block") + require.NotNil(suite.T(), reader, "Reader expected") + data, dataErr := ioutil.ReadAll(reader) + require.Nil(suite.T(), dataErr, "No error expected reading block") + assert.Equal(suite.T(), expected, data, "Data mismatch") +} + +func (suite *ResourceSelectorSuite) IsCompoundList(id resource.ID) bool { + assert.Equal(suite.T(), resource.ID(1000), id, "Unknown resource queried") + return suite.isCompoundList +} + +func (suite *ResourceSelectorSuite) Filter(lang resource.Language, id resource.ID) resource.List { + return suite.resources +} diff --git a/modding/hacked/ss1/resource/Store.go b/modding/hacked/ss1/resource/Store.go new file mode 100644 index 0000000..4760b24 --- /dev/null +++ b/modding/hacked/ss1/resource/Store.go @@ -0,0 +1,85 @@ +package resource + +import "io/ioutil" + +// Store holds a set of resources. This set can be modified. +type Store struct { + ids []ID + resources map[ID]*Resource +} + +// IDs returns a list of available IDs this store currently contains. +func (store Store) IDs() []ID { + ids := make([]ID, 0, len(store.resources)) + for _, id := range store.ids { + if _, existing := store.resources[id]; existing { + ids = append(ids, id) + } + } + return ids +} + +// Resource returns reference to the contained resource for given identifier. +func (store Store) Resource(id ID) (*Resource, error) { + res, existing := store.resources[id] + if !existing { + return nil, ErrNotFound(id) + } + return res, nil +} + +// View returns a read-only view on the resource for given identifier. +func (store Store) View(id ID) (View, error) { + return store.Resource(id) +} + +// Del removes the resource with given identifier from the store. +func (store *Store) Del(id ID) bool { + _, existing := store.resources[id] + if existing { + delete(store.resources, id) + } + return existing +} + +// Put (re-)assigns an identifier with data. If no resource with given ID exists, +// then it is created. Existing resources are overwritten with the provided data. +func (store *Store) Put(id ID, view View) error { + data := make([][]byte, view.BlockCount()) + for index := 0; index < len(data); index++ { + reader, err := view.Block(index) + if err != nil { + return err + } + data[index], err = ioutil.ReadAll(reader) + if err != nil { + return err + } + } + + res := &Resource{ + Properties: Properties{ + Compound: view.Compound(), + ContentType: view.ContentType(), + Compressed: view.Compressed(), + }, + Blocks: BlocksFrom(data), + } + if store.resources == nil { + store.resources = make(map[ID]*Resource) + } + store.resources[id] = res + if store.findIDIndex(id) < 0 { + store.ids = append(store.ids, id) + } + return nil +} + +func (store Store) findIDIndex(id ID) int { + for index := 0; index < len(store.ids); index++ { + if store.ids[index] == id { + return index + } + } + return -1 +} diff --git a/modding/hacked/ss1/resource/Store_test.go b/modding/hacked/ss1/resource/Store_test.go new file mode 100644 index 0000000..fd9ad02 --- /dev/null +++ b/modding/hacked/ss1/resource/Store_test.go @@ -0,0 +1,146 @@ +package resource_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/inkyblackness/hacked/ss1/resource" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type StoreSuite struct { + suite.Suite + + store resource.Store + resourceCounter int +} + +func TestStoreSuite(t *testing.T) { + suite.Run(t, new(StoreSuite)) +} + +func (suite *StoreSuite) SetupTest() { + suite.store = resource.Store{} +} + +func (suite *StoreSuite) TestNewInstanceIsEmpty() { + suite.whenInstanceIsCreated() + suite.thenIDsShouldBeEmpty() +} + +func (suite *StoreSuite) TestResourceReturnsErrorForUnknownResource() { + suite.whenInstanceIsCreated() + suite.thenViewShouldReturnErrorFor(resource.ID(10)) +} + +func (suite *StoreSuite) TestDelWillHaveStoreIgnorePreviousEntry() { + suite.givenAnInstance() + suite.givenStoredResource(resource.ID(1), suite.aResource()) + suite.givenStoredResource(resource.ID(2), suite.aResource()) + suite.whenResourceIsDeleted(resource.ID(2)) + suite.thenIDsShouldBe([]resource.ID{resource.ID(1)}) + suite.thenViewShouldReturnErrorFor(resource.ID(2)) +} + +func (suite *StoreSuite) TestDelWillHaveStoreReportFewerIDs() { + suite.givenAnInstance() + suite.givenStoredResource(resource.ID(1), suite.aResource()) + suite.givenStoredResource(resource.ID(2), suite.aResource()) + suite.givenStoredResource(resource.ID(3), suite.aResource()) + suite.givenStoredResource(resource.ID(4), suite.aResource()) + suite.whenResourceIsDeleted(resource.ID(2)) + suite.thenIDsShouldBe([]resource.ID{resource.ID(1), resource.ID(3), resource.ID(4)}) +} + +func (suite *StoreSuite) TestPutOverridesPreviousResources() { + suite.givenAnInstance() + suite.givenStoredResource(resource.ID(2), suite.aResource()) + suite.givenStoredResource(resource.ID(1), suite.aResource()) + newRes := suite.aResource() + suite.whenResourceIsPut(resource.ID(2), newRes) + suite.thenIDsShouldBe([]resource.ID{resource.ID(2), resource.ID(1)}) + suite.thenReturnedViewShouldBe(resource.ID(2), newRes) +} + +func (suite *StoreSuite) TestDelWillHaveStoreIgnoreResourceEvenIfPutMultipleTimes() { + suite.givenAnInstance() + suite.givenStoredResource(resource.ID(1), suite.aResource()) + suite.givenStoredResource(resource.ID(2), suite.aResource()) + suite.givenStoredResource(resource.ID(2), suite.aResource()) + suite.whenResourceIsDeleted(resource.ID(2)) + suite.thenIDsShouldBe([]resource.ID{resource.ID(1)}) + suite.thenViewShouldReturnErrorFor(resource.ID(2)) +} + +func (suite *StoreSuite) TestPutAddsNewResourcesAtEnd() { + suite.givenAnInstance() + suite.givenStoredResource(resource.ID(2), suite.aResource()) + suite.givenStoredResource(resource.ID(1), suite.aResource()) + newRes := suite.aResource() + suite.whenResourceIsPut(resource.ID(3), newRes) + suite.thenIDsShouldBe([]resource.ID{resource.ID(2), resource.ID(1), resource.ID(3)}) + suite.thenReturnedViewShouldBe(resource.ID(3), newRes) +} + +func (suite *StoreSuite) TestPutRestoresIDAtOldPosition() { + suite.givenAnInstance() + suite.givenStoredResource(resource.ID(2), suite.aResource()) + suite.givenStoredResource(resource.ID(1), suite.aResource()) + suite.givenResourceWasDeleted(resource.ID(2)) + newRes := suite.aResource() + suite.whenResourceIsPut(resource.ID(2), newRes) + suite.thenIDsShouldBe([]resource.ID{resource.ID(2), resource.ID(1)}) +} + +func (suite *StoreSuite) givenAnInstance() { + suite.whenInstanceIsCreated() +} + +func (suite *StoreSuite) givenStoredResource(id resource.ID, res *resource.Resource) { + err := suite.store.Put(id, res) + require.Nil(suite.T(), err, "No error expected storing resource") +} + +func (suite *StoreSuite) givenResourceWasDeleted(id resource.ID) { + suite.whenResourceIsDeleted(id) +} + +func (suite *StoreSuite) whenInstanceIsCreated() { + suite.store = resource.Store{} +} + +func (suite *StoreSuite) whenResourceIsDeleted(id resource.ID) { + suite.store.Del(id) +} + +func (suite *StoreSuite) whenResourceIsPut(id resource.ID, res *resource.Resource) { + err := suite.store.Put(id, res) + require.Nil(suite.T(), err, "No error expected putting resource") +} + +func (suite *StoreSuite) thenIDsShouldBeEmpty() { + assert.Equal(suite.T(), 0, len(suite.store.IDs())) +} + +func (suite *StoreSuite) thenIDsShouldBe(expected []resource.ID) { + assert.Equal(suite.T(), expected, suite.store.IDs()) +} + +func (suite *StoreSuite) thenReturnedViewShouldBe(id resource.ID, expected *resource.Resource) { + res, err := suite.store.View(id) + assert.Nil(suite.T(), err, "No error expected for ID %v", id) + assert.Equal(suite.T(), expected, res, "Different res returned for ID %v", id) +} + +func (suite *StoreSuite) thenViewShouldReturnErrorFor(id resource.ID) { + _, err := suite.store.View(id) + assert.Error(suite.T(), err, "Error expected for ID %v ", id) // nolint: vet +} + +func (suite *StoreSuite) aResource() *resource.Resource { + suite.resourceCounter++ + return &resource.Resource{Blocks: resource.BlocksFrom([][]byte{{byte(suite.resourceCounter)}})} +} diff --git a/modding/hacked/ss1/resource/View.go b/modding/hacked/ss1/resource/View.go new file mode 100644 index 0000000..913b4d0 --- /dev/null +++ b/modding/hacked/ss1/resource/View.go @@ -0,0 +1,29 @@ +package resource + +import ( + "io" +) + +// View is a read-only view on a selected resource. +type View interface { + + // Compound tells whether the resource should be serialized with a directory. + // Compound resources can have zero, one, or more blocks. + // Simple resources always have exactly one block. + Compound() bool + + // ContentType describes how the block data shall be interpreted. + ContentType() ContentType + + // Compressed tells whether the data shall be serialized in compressed form. + Compressed() bool + + // BlockCount returns the number of available blocks in the resource. + // Simple resources will always have exactly one block. + BlockCount() int + + // Block returns the reader for the identified block. + // Each call returns a new reader instance. + // Data provided by this reader is always uncompressed. + Block(index int) (io.Reader, error) +} diff --git a/modding/hacked/ss1/resource/ViewStrategy.go b/modding/hacked/ss1/resource/ViewStrategy.go new file mode 100644 index 0000000..e9f4e22 --- /dev/null +++ b/modding/hacked/ss1/resource/ViewStrategy.go @@ -0,0 +1,9 @@ +package resource + +// ViewStrategy defines how selected resources shall be viewed. +type ViewStrategy interface { + // IsCompoundList returns true for compound resources where each contained block is a separate entity. + // Separate entities are those that can be replaced without affecting others. + // Examples are the small game textures and the list of object names. + IsCompoundList(id ID) bool +} diff --git a/modding/hacked/ss1/resource/Viewer.go b/modding/hacked/ss1/resource/Viewer.go new file mode 100644 index 0000000..a985e18 --- /dev/null +++ b/modding/hacked/ss1/resource/Viewer.go @@ -0,0 +1,10 @@ +package resource + +// Viewer provides resources from some storage. +type Viewer interface { + // IDs returns a list of available IDs this viewer can provide. + IDs() []ID + + // View returns a read-only view to a resource for the given identifier. + View(id ID) (View, error) +} diff --git a/modding/hacked/ss1/resource/doc.go b/modding/hacked/ss1/resource/doc.go new file mode 100644 index 0000000..27eba71 --- /dev/null +++ b/modding/hacked/ss1/resource/doc.go @@ -0,0 +1,4 @@ +/* +Package resource provides interfaces and primitives for low-level handling of resources. +*/ +package resource diff --git a/modding/hacked/ss1/resource/lgres/BlockReader.go b/modding/hacked/ss1/resource/lgres/BlockReader.go new file mode 100644 index 0000000..c491efe --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/BlockReader.go @@ -0,0 +1,22 @@ +package lgres + +import "io" + +type blockFunc func(index int) (io.Reader, error) + +type blockReader struct { + blockCount int + blockFunc blockFunc +} + +// BlockCount returns the number of available blocks. +func (reader *blockReader) BlockCount() int { + return reader.blockCount +} + +// Block returns the reader for the identified block. +// Each call returns a new reader instance. +// Data provided by this reader is always uncompressed. +func (reader *blockReader) Block(index int) (io.Reader, error) { + return reader.blockFunc(index) +} diff --git a/modding/hacked/ss1/resource/lgres/BlockWriter.go b/modding/hacked/ss1/resource/lgres/BlockWriter.go new file mode 100644 index 0000000..0923858 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/BlockWriter.go @@ -0,0 +1,24 @@ +package lgres + +import "io" + +// BlockWriter is for writing data of a single block in a resource. +type BlockWriter struct { + target io.Writer + finisher func() + bytesWritten uint32 +} + +func (writer *BlockWriter) finish() (length uint32) { + writer.finisher() + writer.finisher = nil + writer.target = nil + return writer.bytesWritten +} + +// Write stores the given data in the block and follows the Writer interface. +func (writer *BlockWriter) Write(data []byte) (written int, err error) { + written, err = writer.target.Write(data) + writer.bytesWritten += uint32(written) + return +} diff --git a/modding/hacked/ss1/resource/lgres/FragmentedChunkWriter.go b/modding/hacked/ss1/resource/lgres/FragmentedChunkWriter.go new file mode 100644 index 0000000..3b619bd --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/FragmentedChunkWriter.go @@ -0,0 +1,64 @@ +package lgres + +import ( + "io" + + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// CompoundResourceWriter writes a resource with zero, one, or more blocks. +// Multiple blocks can be created and then written concurrently. Only when the +// resource is finished, the blocks are finalized. +type CompoundResourceWriter struct { + target *serial.PositioningEncoder + + compressed bool + dataPaddingSize int + blockStores []*serial.ByteStore + blockWriter []*BlockWriter +} + +// CreateBlock provides a new, dedicated writer for a new block. +func (writer *CompoundResourceWriter) CreateBlock() *BlockWriter { + store := serial.NewByteStore() + blockWriter := &BlockWriter{target: serial.NewEncoder(store), finisher: func() {}} + writer.blockStores = append(writer.blockStores, store) + writer.blockWriter = append(writer.blockWriter, blockWriter) + return blockWriter +} + +func (writer *CompoundResourceWriter) finish() (length uint32) { + var unpackedSize uint32 + blockCount := len(writer.blockStores) + writer.target.Code(uint16(blockCount)) + offset := 2 + (blockCount+1)*4 + writer.dataPaddingSize + for index, store := range writer.blockStores { + unpackedSize += writer.blockWriter[index].finish() + writer.target.Code(uint32(offset)) + offset += len(store.Data()) + } + writer.target.Code(uint32(offset)) + unpackedSize += writer.target.CurPos() + uint32(writer.dataPaddingSize) + + writer.writeBlocks() + + return unpackedSize +} + +func (writer *CompoundResourceWriter) writeBlocks() { + var targetWriter io.Writer = writer.target + targetFinisher := func() {} + if writer.compressed { + compressor := compression.NewCompressor(targetWriter) + targetWriter = compressor + targetFinisher = func() { compressor.Close() } // nolint: errcheck + } + for i := 0; i < writer.dataPaddingSize; i++ { + targetWriter.Write([]byte{0x00}) // nolint: errcheck + } + for _, store := range writer.blockStores { + targetWriter.Write(store.Data()) // nolint: errcheck + } + targetFinisher() +} diff --git a/modding/hacked/ss1/resource/lgres/Reader.go b/modding/hacked/ss1/resource/lgres/Reader.go new file mode 100644 index 0000000..414b13c --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/Reader.go @@ -0,0 +1,256 @@ +package lgres + +import ( + "bytes" + "encoding/binary" + "io" + "io/ioutil" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// Reader provides methods to extract resource data from a serialized form. +// Resources may be accessed out of sequence due to the nature of the underlying io.ReaderAt. +type Reader struct { + source io.ReaderAt + firstResourceOffset uint32 + directoryOffset uint32 + directory []resourceDirectoryEntry + + cache map[uint16]resource.View +} + +const ( + // ErrSourceNil is returned if the source is not valid. + ErrSourceNil ss1.StringError = "source is nil" + + // ErrFormatMismatch is returned if the format is unknown. + ErrFormatMismatch ss1.StringError = "format mismatch" +) + +// ReaderFrom accesses the provided source and creates a new Reader instance +// from it. +// Should the provided decoder not follow the resource file format, an error +// is returned. +func ReaderFrom(source io.ReaderAt) (reader *Reader, err error) { + if source == nil { + return nil, ErrSourceNil + } + + var dirOffset uint32 + dirOffset, err = readAndVerifyHeader(io.NewSectionReader(source, 0, format.ResourceDirectoryFileOffsetPos+4)) + if err != nil { + return nil, err + } + firstResourceOffset, directory, err := readDirectoryAt(dirOffset, source) + if err != nil { + return nil, err + } + + reader = &Reader{ + source: source, + firstResourceOffset: firstResourceOffset, + directoryOffset: dirOffset, + directory: directory, + cache: make(map[uint16]resource.View)} + + return +} + +// IDs returns the resource identifier available via this reader. +// The order in the slice is the same as in the underlying serialized form. +func (reader *Reader) IDs() []resource.ID { + ids := make([]resource.ID, len(reader.directory)) + for index, entry := range reader.directory { + ids[index] = resource.ID(entry.ID) + } + return ids +} + +// View returns a reader prepared to extract data for the specified resource. +// An error is returned if either the ID is not known, or the resource could not be prepared. +func (reader *Reader) View(id resource.ID) (retrievedResource resource.View, err error) { + if cachedResource, existing := reader.cache[id.Value()]; existing { + return cachedResource, nil + } + resourceStartOffset, entry := reader.findEntry(id.Value()) + if entry == nil { + return nil, resource.ErrNotFound(id) + } + resourceType := entry.resourceType() + compressed := (resourceType & format.ResourceTypeFlagCompressed) != 0 + compound := (resourceType & format.ResourceTypeFlagCompound) != 0 + contentType := resource.ContentType(entry.contentType()) + + if compound { + retrievedResource, err = reader.newCompoundResourceReader(entry, contentType, compressed, resourceStartOffset) + } else { + retrievedResource, err = reader.newSingleBlockResourceReader(entry, contentType, compressed, resourceStartOffset) + } + if err == nil { + reader.cache[id.Value()] = retrievedResource + } + return +} + +func readAndVerifyHeader(source io.ReadSeeker) (dirOffset uint32, err error) { + coder := serial.NewPositioningDecoder(source) + data := make([]byte, format.ResourceDirectoryFileOffsetPos) + coder.Code(data) + coder.Code(&dirOffset) + + expected := make([]byte, len(format.HeaderString)+1) + for index, r := range format.HeaderString { + expected[index] = byte(r) + } + expected[len(format.HeaderString)] = format.CommentTerminator + if !bytes.Equal(data[:len(expected)], expected) { + return 0, ErrFormatMismatch + } + + return dirOffset, coder.FirstError() +} + +func readDirectoryAt(dirOffset uint32, source io.ReaderAt) (firstResourceOffset uint32, directory []resourceDirectoryEntry, err error) { + var header resourceDirectoryHeader + headerSize := int64(binary.Size(&header)) + { + headerCoder := serial.NewDecoder(io.NewSectionReader(source, int64(dirOffset), headerSize)) + headerCoder.Code(&header) + if headerCoder.FirstError() != nil { + return 0, nil, headerCoder.FirstError() + } + } + + firstResourceOffset = header.FirstResourceOffset + directory = make([]resourceDirectoryEntry, header.ResourceCount) + if header.ResourceCount > 0 { + listCoder := serial.NewDecoder(io.NewSectionReader(source, int64(dirOffset)+headerSize, int64(binary.Size(directory)))) + listCoder.Code(directory) + err = listCoder.FirstError() + } + return +} + +func (reader *Reader) findEntry(id uint16) (startOffset uint32, entry *resourceDirectoryEntry) { + startOffset = reader.firstResourceOffset + for index := 0; (index < len(reader.directory)) && (entry == nil); index++ { + cur := &reader.directory[index] + if cur.ID == id { + entry = cur + } else { + startOffset += cur.packedLength() + startOffset += (format.BoundarySize - (startOffset % format.BoundarySize)) % format.BoundarySize + } + } + return +} + +type blockListEntry struct { + start uint32 + size uint32 +} + +func (reader *Reader) newCompoundResourceReader(entry *resourceDirectoryEntry, + contentType resource.ContentType, compressed bool, resourceStartOffset uint32) (resource.View, error) { + resourceDataReader := io.NewSectionReader(reader.source, int64(resourceStartOffset), int64(entry.packedLength())) + + firstBlockOffset, blockList, err := reader.readBlockList(resourceDataReader) + if err != nil { + return nil, err + } + blockCount := len(blockList) + + rawBlockDataReader := io.NewSectionReader(resourceDataReader, int64(firstBlockOffset), resourceDataReader.Size()-int64(firstBlockOffset)) + var uncompressedReader io.ReaderAt + if !compressed { + uncompressedReader = rawBlockDataReader + } + + blockFunc := func(index int) (io.Reader, error) { + if (index < 0) || (index >= blockCount) { + return nil, resource.ErrBlockNotFound(index, blockCount) + } + + if compressed && (uncompressedReader == nil) { + decompressor := compression.NewDecompressor(rawBlockDataReader) + decompressedData, err := ioutil.ReadAll(decompressor) + if err != nil { + return nil, err + } + uncompressedReader = bytes.NewReader(decompressedData) + } + + entry := blockList[index] + reader := io.NewSectionReader(uncompressedReader, int64(entry.start)-int64(firstBlockOffset), int64(entry.size)) + return reader, nil + } + + return &readerResource{ + Properties: resource.Properties{ + Compound: true, + ContentType: contentType, + Compressed: compressed, + }, + blockReader: blockReader{blockCount: len(blockList), blockFunc: blockFunc}}, nil +} + +func (reader *Reader) readBlockList(source io.Reader) (uint32, []blockListEntry, error) { + listDecoder := serial.NewDecoder(source) + var blockCount uint16 + listDecoder.Code(&blockCount) + var firstBlockOffset uint32 + listDecoder.Code(&firstBlockOffset) + lastBlockEndOffset := firstBlockOffset + blockList := make([]blockListEntry, blockCount) + for blockIndex := uint16(0); blockIndex < blockCount; blockIndex++ { + var endOffset uint32 + listDecoder.Code(&endOffset) + blockList[blockIndex].start = lastBlockEndOffset + blockList[blockIndex].size = endOffset - lastBlockEndOffset + lastBlockEndOffset = endOffset + } + + return firstBlockOffset, blockList, listDecoder.FirstError() +} + +func (reader *Reader) newSingleBlockResourceReader(entry *resourceDirectoryEntry, + contentType resource.ContentType, compressed bool, resourceStartOffset uint32) (resource.View, error) { + blockFunc := func(index int) (io.Reader, error) { + if index != 0 { + return nil, resource.ErrBlockNotFound(index, 1) + } + resourceSize := entry.packedLength() + // The following check is a hack to re-create the behaviour of the engine. + // While in truth the resource should only cover what its entry needs, Movie resources + // can get larger than the 24bit size-field would allow (16MiB). + // Interestingly, the engine does not limit this and simply reads beyond the limit. + // In order to support such "broken" files, the behaviour is re-created here. This is mainly to + // still allow creating such files and also give the editor a chance to work (and display such a case) + // without completely having to rework the whole architecture here. + // As audio of logs is also stored with this content type, limit to only single-resource files + // to avoid a sudden increase of other resources in the file. + isCutscene := (resource.ContentType(entry.contentType()) == resource.Movie) && (len(reader.directory) == 1) + if isCutscene && (resourceStartOffset < reader.directoryOffset) { + resourceSize = reader.directoryOffset - resourceStartOffset + } + var resourceSource io.Reader = io.NewSectionReader(reader.source, int64(resourceStartOffset), int64(resourceSize)) + if compressed { + resourceSize = entry.unpackedLength() + resourceSource = compression.NewDecompressor(resourceSource) + } + return io.LimitReader(resourceSource, int64(resourceSize)), nil + } + + return &readerResource{ + Properties: resource.Properties{ + Compound: false, + ContentType: contentType, + Compressed: compressed, + }, + blockReader: blockReader{blockCount: 1, blockFunc: blockFunc}}, nil +} diff --git a/modding/hacked/ss1/resource/lgres/ReaderResource.go b/modding/hacked/ss1/resource/lgres/ReaderResource.go new file mode 100644 index 0000000..012ac3c --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/ReaderResource.go @@ -0,0 +1,20 @@ +package lgres + +import "github.com/inkyblackness/hacked/ss1/resource" + +type readerResource struct { + resource.Properties + blockReader +} + +func (rr readerResource) Compound() bool { + return rr.Properties.Compound +} + +func (rr readerResource) ContentType() resource.ContentType { + return rr.Properties.ContentType +} + +func (rr readerResource) Compressed() bool { + return rr.Properties.Compressed +} diff --git a/modding/hacked/ss1/resource/lgres/Reader_test.go b/modding/hacked/ss1/resource/lgres/Reader_test.go new file mode 100644 index 0000000..5f19aca --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/Reader_test.go @@ -0,0 +1,155 @@ +package lgres_test + +import ( + "bytes" + "fmt" + "io/ioutil" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres" + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReaderFromReturnsErrorForNilSource(t *testing.T) { + reader, err := lgres.ReaderFrom(nil) + + assert.Nil(t, reader, "reader should be nil") + assert.Equal(t, lgres.ErrSourceNil, err) +} + +func TestReaderFromReturnsInstanceOnEmptySource(t *testing.T) { + source := bytes.NewReader(emptyResourceFile()) + reader, err := lgres.ReaderFrom(source) + + assert.Nil(t, err, "Error should be nil") + assert.NotNil(t, reader) +} + +func TestReaderFromReturnsErrorOnInvalidHeaderString(t *testing.T) { + sourceData := emptyResourceFile() + sourceData[10] = "A"[0] + + _, err := lgres.ReaderFrom(bytes.NewReader(sourceData)) + + assert.Equal(t, lgres.ErrFormatMismatch, err) +} + +func TestReaderFromReturnsErrorOnMissingCommentTerminator(t *testing.T) { + sourceData := emptyResourceFile() + sourceData[len(format.HeaderString)] = byte(0) + + _, err := lgres.ReaderFrom(bytes.NewReader(sourceData)) + + assert.Equal(t, lgres.ErrFormatMismatch, err) +} + +func TestReaderFromReturnsErrorOnInvalidDirectoryStart(t *testing.T) { + sourceData := emptyResourceFile() + sourceData[format.ResourceDirectoryFileOffsetPos] = byte(0xFF) + sourceData[format.ResourceDirectoryFileOffsetPos+1] = byte(0xFF) + + _, err := lgres.ReaderFrom(bytes.NewReader(sourceData)) + + assert.NotNil(t, err, "error expected") +} + +func TestReaderFromCanDecodeExampleResourceFile(t *testing.T) { + _, err := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + assert.Nil(t, err, "no error expected") +} + +func TestReaderIDsReturnsTheStoredResourceIDsInOrderFromFile(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + + assert.Equal(t, []resource.ID{exampleResourceIDSingleBlockResource, exampleResourceIDSingleBlockResourceCompressed, + exampleResourceIDCompoundResource, exampleResourceIDCompoundResourceCompressed}, reader.IDs()) +} + +func TestReaderResourceReturnsErrorForUnknownID(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(emptyResourceFile())) + resourceReader, err := reader.View(resource.ID(0x1111)) + assert.Nil(t, resourceReader, "no reader expected") + assert.NotNil(t, err) +} + +func TestReaderResourceReturnsAResourceReaderForKnownID(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + resourceReader, err := reader.View(exampleResourceIDSingleBlockResource) + assert.Nil(t, err, "no error expected") + assert.NotNil(t, resourceReader) +} + +func TestReaderResourceReturnsResourceWithMetaInformation(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + info := func(resourceID resource.ID, name string, expected interface{}) string { + return fmt.Sprintf("Resource 0x%04X should have %v = %v", resourceID.Value(), name, expected) + } + verifyResource := func(resourceID resource.ID, compound bool, contentType resource.ContentType, compressed bool) { + resourceReader, _ := reader.View(resourceID) + assert.Equal(t, compound, resourceReader.Compound(), info(resourceID, "compound", compound)) + assert.Equal(t, contentType, resourceReader.ContentType(), info(resourceID, "contentType", contentType)) + assert.Equal(t, compressed, resourceReader.Compressed(), info(resourceID, "compressed", compressed)) + } + verifyResource(exampleResourceIDSingleBlockResource, false, resource.ContentType(0x01), false) + verifyResource(exampleResourceIDSingleBlockResourceCompressed, false, resource.ContentType(0x02), true) + verifyResource(exampleResourceIDCompoundResource, true, resource.ContentType(0x03), false) + verifyResource(exampleResourceIDCompoundResourceCompressed, true, resource.ContentType(0x04), true) +} + +func TestReaderResourceReturnsSameInstance(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + + c1, _ := reader.View(exampleResourceIDSingleBlockResource) + c2, _ := reader.View(exampleResourceIDSingleBlockResource) + + assert.Equal(t, c1, c2, "Resources should be the same") +} + +func TestReaderResourceWithUncompressedSingleBlockContent(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + resourceReader, _ := reader.View(exampleResourceIDSingleBlockResource) + + assert.Equal(t, 1, resourceReader.BlockCount()) + verifyBlockContent(t, resourceReader, 0, []byte{0x01, 0x01, 0x01}) +} + +func TestReaderResourceWithCompressedSingleBlockContent(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + resourceReader, _ := reader.View(exampleResourceIDSingleBlockResourceCompressed) + + assert.Equal(t, 1, resourceReader.BlockCount()) + verifyBlockContent(t, resourceReader, 0, []byte{0x02, 0x02}) +} + +func TestReaderResourceWithUncompressedCompoundContent(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + resourceReader, _ := reader.View(exampleResourceIDCompoundResource) + + assert.Equal(t, 2, resourceReader.BlockCount()) + verifyBlockContent(t, resourceReader, 0, []byte{0x30, 0x30, 0x30, 0x30}) + verifyBlockContent(t, resourceReader, 1, []byte{0x31, 0x31, 0x31}) +} + +func TestReaderResourceWithCompressedCompoundContent(t *testing.T) { + reader, _ := lgres.ReaderFrom(bytes.NewReader(exampleResourceFile())) + resourceReader, _ := reader.View(exampleResourceIDCompoundResourceCompressed) + + assert.Equal(t, 3, resourceReader.BlockCount()) + verifyBlockContent(t, resourceReader, 0, []byte{0x40, 0x40}) + verifyBlockContent(t, resourceReader, 1, []byte{0x41, 0x41, 0x41, 0x41}) + verifyBlockContent(t, resourceReader, 2, []byte{0x42}) +} + +func verifyBlockContent(t *testing.T, blockProvider resource.BlockProvider, blockIndex int, expected []byte) { + t.Helper() + blockReader, readerErr := blockProvider.Block(blockIndex) + assert.Nil(t, readerErr, "error retrieving reader") + require.NotNil(t, blockReader, "reader is nil") + data, dataErr := ioutil.ReadAll(blockReader) + assert.Nil(t, dataErr, "no error expected reading data") + assert.Equal(t, expected, data) +} diff --git a/modding/hacked/ss1/resource/lgres/ResourceDirectoryEntry.go b/modding/hacked/ss1/resource/lgres/ResourceDirectoryEntry.go new file mode 100644 index 0000000..99e7f6a --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/ResourceDirectoryEntry.go @@ -0,0 +1,52 @@ +package lgres + +import ( + "math" +) + +type resourceDirectoryEntry struct { + ID uint16 + UnpackedLengthAndResourceType uint32 + PackedLengthAndContentType uint32 +} + +func maskBits(field uint32, bitOffset uint, bitCount int) uint32 { + return (field >> bitOffset) & uint32(^(uint64(math.MaxUint64) << uint64(bitCount))) +} + +func setBits(field uint32, bitOffset uint, bitCount int, value uint32) uint32 { + mask := uint32(^(uint64(math.MaxUint64) << uint64(bitCount))) + return (field & (^mask << bitOffset)) | ((value & mask) << bitOffset) +} + +func (entry *resourceDirectoryEntry) setUnpackedLength(value uint32) { + entry.UnpackedLengthAndResourceType = setBits(entry.UnpackedLengthAndResourceType, 0, 24, value) +} + +func (entry *resourceDirectoryEntry) unpackedLength() uint32 { + return maskBits(entry.UnpackedLengthAndResourceType, 0, 24) +} + +func (entry *resourceDirectoryEntry) setResourceType(value byte) { + entry.UnpackedLengthAndResourceType = setBits(entry.UnpackedLengthAndResourceType, 24, 8, uint32(value)) +} + +func (entry *resourceDirectoryEntry) resourceType() byte { + return byte(maskBits(entry.UnpackedLengthAndResourceType, 24, 8)) +} + +func (entry *resourceDirectoryEntry) setPackedLength(value uint32) { + entry.PackedLengthAndContentType = setBits(entry.PackedLengthAndContentType, 0, 24, value) +} + +func (entry *resourceDirectoryEntry) packedLength() uint32 { + return maskBits(entry.PackedLengthAndContentType, 0, 24) +} + +func (entry *resourceDirectoryEntry) setContentType(value byte) { + entry.PackedLengthAndContentType = setBits(entry.PackedLengthAndContentType, 24, 8, uint32(value)) +} + +func (entry *resourceDirectoryEntry) contentType() byte { + return byte(maskBits(entry.PackedLengthAndContentType, 24, 8)) +} diff --git a/modding/hacked/ss1/resource/lgres/ResourceDirectoryHeader.go b/modding/hacked/ss1/resource/lgres/ResourceDirectoryHeader.go new file mode 100644 index 0000000..dcd62a9 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/ResourceDirectoryHeader.go @@ -0,0 +1,6 @@ +package lgres + +type resourceDirectoryHeader struct { + ResourceCount uint16 + FirstResourceOffset uint32 +} diff --git a/modding/hacked/ss1/resource/lgres/ResourceWriter.go b/modding/hacked/ss1/resource/lgres/ResourceWriter.go new file mode 100644 index 0000000..f172813 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/ResourceWriter.go @@ -0,0 +1,8 @@ +package lgres + +type resourceWriter interface { + // finish signals the writer that the resource is to be finished. + // Any pending data needs to be sent to the output and the uncompressed + // size shall be returned. + finish() uint32 +} diff --git a/modding/hacked/ss1/resource/lgres/Write.go b/modding/hacked/ss1/resource/lgres/Write.go new file mode 100644 index 0000000..9c3100f --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/Write.go @@ -0,0 +1,72 @@ +package lgres + +import ( + "fmt" + "io" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +type resourceInconsistentError struct { + ID resource.ID + BlockCount int +} + +func (err resourceInconsistentError) Error() string { + return fmt.Sprintf("simple resource %v has invalid number of blocks: %d", err.ID, err.BlockCount) +} + +// Write serializes the resources from given source into the target. +// It is a convenience function for using Writer. +func Write(target io.WriteSeeker, source resource.Viewer) error { + writer, writerErr := NewWriter(target) + if writerErr != nil { + return writerErr + } + + for _, id := range source.IDs() { + entry, resourceErr := source.View(id) + if resourceErr != nil { + return resourceErr + } + + switch { + case entry.Compound(): + resourceWriter, resourceWriterErr := writer.CreateCompoundResource(id, entry.ContentType(), entry.Compressed()) + if resourceWriterErr != nil { + return resourceWriterErr + } + copyErr := copyBlocks(entry, func() io.Writer { return resourceWriter.CreateBlock() }) + if copyErr != nil { + return copyErr + } + case entry.BlockCount() == 1: + blockWriter, resourceWriterErr := writer.CreateResource(id, entry.ContentType(), entry.Compressed()) + if resourceWriterErr != nil { + return resourceWriterErr + } + copyErr := copyBlocks(entry, func() io.Writer { return blockWriter }) + if copyErr != nil { + return copyErr + } + default: + return resourceInconsistentError{ID: id, BlockCount: entry.BlockCount()} + } + } + + return writer.Finish() +} + +func copyBlocks(source resource.BlockProvider, nextWriter func() io.Writer) error { + for blockIndex := 0; blockIndex < source.BlockCount(); blockIndex++ { + blockReader, blockErr := source.Block(blockIndex) + if blockErr != nil { + return blockErr + } + _, copyErr := io.Copy(nextWriter(), blockReader) + if copyErr != nil { + return copyErr + } + } + return nil +} diff --git a/modding/hacked/ss1/resource/lgres/Write_test.go b/modding/hacked/ss1/resource/lgres/Write_test.go new file mode 100644 index 0000000..0918f82 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/Write_test.go @@ -0,0 +1,44 @@ +package lgres_test + +import ( + "bytes" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" +) + +func TestWrite(t *testing.T) { + target := serial.NewByteStore() + var store resource.Store + aResource := func(compressed bool, contentType resource.ContentType, compound bool, blocks resource.Blocks) resource.View { + return resource.Resource{ + Properties: resource.Properties{ + Compressed: compressed, + ContentType: contentType, + Compound: compound, + }, + Blocks: blocks, + } + } + + _ = store.Put(resource.ID(1), aResource(false, resource.Bitmap, false, resource.BlocksFrom([][]byte{{0x11}}))) + _ = store.Put(resource.ID(3), aResource(false, resource.Font, true, resource.BlocksFrom([][]byte{{0x21}, {0x22, 0x23}}))) + _ = store.Put(resource.ID(2), aResource(true, resource.Geometry, false, resource.BlocksFrom([][]byte{{0x31}}))) + _ = store.Put(resource.ID(4), aResource(true, resource.Archive, true, resource.BlocksFrom([][]byte{{0x41}, {0x42, 0x43}}))) + + errWrite := lgres.Write(target, store) + if errWrite != nil { + assert.Nil(t, errWrite, "no error expected writing") + } + + reader, errReader := lgres.ReaderFrom(bytes.NewReader(target.Data())) + if errReader != nil { + assert.Nil(t, errReader, "no error expected reading") + } + + assert.Equal(t, []resource.ID{resource.ID(1), resource.ID(3), resource.ID(2), resource.ID(4)}, reader.IDs()) +} diff --git a/modding/hacked/ss1/resource/lgres/Writer.go b/modding/hacked/ss1/resource/lgres/Writer.go new file mode 100644 index 0000000..988f6fc --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/Writer.go @@ -0,0 +1,177 @@ +package lgres + +import ( + "io" + "math" + + "github.com/inkyblackness/hacked/ss1" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// Writer provides methods to write a new resource file from scratch. +// Resources have to be created sequentially. The writer does not support +// concurrent creation and modification of resources. +type Writer struct { + encoder *serial.PositioningEncoder + + firstResourceOffset uint32 + currentResourceStartOffset uint32 + currentResource resourceWriter + + directory []*resourceDirectoryEntry +} + +// ErrTargetNil is returned if the target cannot be used. +const ErrTargetNil ss1.StringError = "target is nil" + +// NewWriter returns a new Writer instance prepared to add resources. +// To finalize the created file, call Finish(). +// +// This function will write initial information to the target and will return +// an error if the writer did. In such a case, the returned writer instance +// will produce invalid results and the state of the target is undefined. +func NewWriter(target io.WriteSeeker) (*Writer, error) { + if target == nil { + return nil, ErrTargetNil + } + + encoder := serial.NewPositioningEncoder(target) + writer := &Writer{encoder: encoder} + writer.writeHeader() + writer.firstResourceOffset = writer.encoder.CurPos() + + return writer, writer.encoder.FirstError() +} + +// ErrWriterFinished is returned if the writer was already finished. +const ErrWriterFinished ss1.StringError = "writer is finished" + +// CreateResource adds a new single-block resource to the current resource file. +// This resource is closed by creating another resource, or by finishing the writer. +func (writer *Writer) CreateResource(id resource.ID, contentType resource.ContentType, + compressed bool) (*BlockWriter, error) { + if writer.encoder == nil { + return nil, ErrWriterFinished + } + + writer.finishLastResource() + if writer.encoder.FirstError() != nil { + return nil, writer.encoder.FirstError() + } + + var targetWriter io.Writer = serial.NewEncoder(writer.encoder) + targetFinisher := func() {} + resourceType := byte(0x00) + if compressed { + compressor := compression.NewCompressor(targetWriter) + resourceType |= format.ResourceTypeFlagCompressed + targetWriter = compressor + targetFinisher = func() { compressor.Close() } // nolint: errcheck + } + blockWriter := &BlockWriter{target: targetWriter, finisher: targetFinisher} + writer.addNewResource(id, contentType, resourceType, blockWriter) + + return blockWriter, nil +} + +// CreateCompoundResource adds a new compound resource to the current resource file. +// This resource is closed by creating another resource, or by finishing the writer. +func (writer *Writer) CreateCompoundResource(id resource.ID, contentType resource.ContentType, + compressed bool) (*CompoundResourceWriter, error) { + if writer.encoder == nil { + return nil, ErrWriterFinished + } + + writer.finishLastResource() + if writer.encoder.FirstError() != nil { + return nil, writer.encoder.FirstError() + } + + resourceType := format.ResourceTypeFlagCompound + if compressed { + resourceType |= format.ResourceTypeFlagCompressed + } + resourceWriter := &CompoundResourceWriter{ + target: serial.NewPositioningEncoder(writer.encoder), + compressed: compressed, + dataPaddingSize: writer.dataPaddingSizeForCompoundResource(id)} + writer.addNewResource(id, contentType, resourceType, resourceWriter) + + return resourceWriter, nil +} + +// Finish finalizes the resource file. After calling this function, the +// writer becomes unusable. +func (writer *Writer) Finish() (err error) { + if writer.encoder == nil { + return ErrWriterFinished + } + + writer.finishLastResource() + + directoryOffset := writer.encoder.CurPos() + writer.encoder.SetCurPos(format.ResourceDirectoryFileOffsetPos) + writer.encoder.Code(directoryOffset) + writer.encoder.SetCurPos(directoryOffset) + writer.encoder.Code(uint16(len(writer.directory))) + writer.encoder.Code(writer.firstResourceOffset) + for _, entry := range writer.directory { + writer.encoder.Code(entry) + } + + err = writer.encoder.FirstError() + writer.encoder = nil + + return +} + +func (writer *Writer) writeHeader() { + header := make([]byte, format.ResourceDirectoryFileOffsetPos) + for index, r := range format.HeaderString { + header[index] = byte(r) + } + header[len(format.HeaderString)] = format.CommentTerminator + writer.encoder.Code(header) + writer.encoder.Code(uint32(math.MaxUint32)) +} + +func (writer *Writer) addNewResource(id resource.ID, contentType resource.ContentType, resourceType byte, newResource resourceWriter) { + entry := &resourceDirectoryEntry{ID: id.Value()} + entry.setContentType(byte(contentType)) + entry.setResourceType(resourceType) + writer.directory = append(writer.directory, entry) + writer.currentResource = newResource + writer.currentResourceStartOffset = writer.encoder.CurPos() +} + +func (writer *Writer) finishLastResource() { + if writer.currentResource != nil { + currentEntry := writer.directory[len(writer.directory)-1] + currentEntry.setUnpackedLength(writer.currentResource.finish()) + currentEntry.setPackedLength(writer.encoder.CurPos() - writer.currentResourceStartOffset) + + writer.currentResourceStartOffset = 0 + writer.currentResource = nil + } + writer.alignToBoundary() +} + +func (writer *Writer) alignToBoundary() { + extraBytes := writer.encoder.CurPos() % format.BoundarySize + if extraBytes > 0 { + padding := make([]byte, format.BoundarySize-extraBytes) + writer.encoder.Code(padding) + } +} + +func (writer *Writer) dataPaddingSizeForCompoundResource(id resource.ID) (padding int) { + // Some directories have a 2byte padding before the actual data + idValue := id.Value() + if (idValue >= 0x08FC) && (idValue <= 0x094B) { // all resources in obj3d.res + padding = 2 + } + return +} diff --git a/modding/hacked/ss1/resource/lgres/Writer_test.go b/modding/hacked/ss1/resource/lgres/Writer_test.go new file mode 100644 index 0000000..183072b --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/Writer_test.go @@ -0,0 +1,196 @@ +package lgres_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres" + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" +) + +func TestNewWriterReturnsErrorForNilTarget(t *testing.T) { + writer, err := lgres.NewWriter(nil) + + assert.Nil(t, writer, "writer should be nil") + assert.Equal(t, lgres.ErrTargetNil, err) +} + +func TestWriterFinishWithoutAddingResourcesCreatesValidFileWithoutResources(t *testing.T) { + emptyFileData := emptyResourceFile() + store := serial.NewByteStore() + writer, err := lgres.NewWriter(store) + assert.Nil(t, err, "no error expected creating writer") + + err = writer.Finish() + assert.Nil(t, err, "no error expected finishing writer") + assert.Equal(t, emptyFileData, store.Data()) +} + +func TestWriterFinishReturnsErrorWhenAlreadyFinished(t *testing.T) { + writer, _ := lgres.NewWriter(serial.NewByteStore()) + + err := writer.Finish() + assert.Nil(t, err, "no error expected finishing") + + err = writer.Finish() + assert.Equal(t, lgres.ErrWriterFinished, err) +} + +func TestWriterUncompressedSingleBlockResourceCanBeWritten(t *testing.T) { + data := []byte{0xAB, 0x01, 0xCD, 0x02, 0xEF} + store := serial.NewByteStore() + writer, _ := lgres.NewWriter(store) + resourceWriter, err := writer.CreateResource(resource.ID(0x1234), resource.ContentType(0x0A), false) + assert.Nil(t, err, "no error expected creating resource") + _, err = resourceWriter.Write(data) + assert.Nil(t, err, "no error expected writing") + err = writer.Finish() + assert.Nil(t, err, "no error expected finishing") + + result := store.Data() + + var expected []byte + expected = append(expected, data...) + expected = append(expected, + 0x00, 0x00, 0x00, // alignment for directory + 0x01, 0x00, // resource count + 0x80, 0x00, 0x00, 0x00, // offset to first resource + 0x34, 0x12, // resource ID + 0x05, 0x00, 0x00, // resource length (uncompressed) + 0x00, // resource type (uncompressed, single-block) + 0x05, 0x00, 0x00, // resource length in file + 0x0A) // content type + assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:]) +} + +func TestWriterUncompressedCompoundResourceCanBeWritten(t *testing.T) { + blockData1 := []byte{0xAB, 0x01, 0xCD} + blockData2 := []byte{0x11, 0x22, 0x33, 0x44} + store := serial.NewByteStore() + writer, _ := lgres.NewWriter(store) + resourceWriter, err := writer.CreateCompoundResource(resource.ID(0x5678), resource.ContentType(0x0B), false) + assert.Nil(t, err, "no error expected creating resource") + _, _ = resourceWriter.CreateBlock().Write(blockData1) + _, _ = resourceWriter.CreateBlock().Write(blockData2) + err = writer.Finish() + assert.Nil(t, err, "no error expected finishing") + + result := store.Data() + + var expected []byte + expected = append(expected, + 0x02, 0x00, // number of blocks + 0x0E, 0x00, 0x00, 0x00, // offset to first block + 0x11, 0x00, 0x00, 0x00, // offset to second block + 0x15, 0x00, 0x00, 0x00) // size of resource + expected = append(expected, blockData1...) + expected = append(expected, blockData2...) + expected = append(expected, + 0x00, 0x00, 0x00, // alignment for directory + 0x01, 0x00, // resource count + 0x80, 0x00, 0x00, 0x00, // offset to first resource + 0x78, 0x56, // resource ID + 0x15, 0x00, 0x00, // resource length (uncompressed) + 0x02, // resource type + 0x15, 0x00, 0x00, // resource length in file + 0x0B) // content type + assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:]) +} + +func TestWriterUncompressedCompoundResourceCanBeWrittenWithPaddingForSpecialID(t *testing.T) { + blockData1 := []byte{0xAB, 0x01, 0xCD} + blockData2 := []byte{0x11, 0x22, 0x33, 0x44} + store := serial.NewByteStore() + writer, _ := lgres.NewWriter(store) + resourceWriter, err := writer.CreateCompoundResource(resource.ID(0x08FD), resource.ContentType(0x0B), false) + assert.Nil(t, err, "no error expected creating resource") + _, _ = resourceWriter.CreateBlock().Write(blockData1) + _, _ = resourceWriter.CreateBlock().Write(blockData2) + err = writer.Finish() + assert.Nil(t, err, "no error expected finishing") + + result := store.Data() + + var expected []byte + expected = append(expected, + 0x02, 0x00, // number of blocks + 0x10, 0x00, 0x00, 0x00, // offset to first block + 0x13, 0x00, 0x00, 0x00, // offset to second block + 0x17, 0x00, 0x00, 0x00, // size of resource + 0x00, 0x00) // padding + expected = append(expected, blockData1...) + expected = append(expected, blockData2...) + expected = append(expected, + 0x00, // alignment for directory + 0x01, 0x00, // resource count + 0x80, 0x00, 0x00, 0x00, // offset to first resource + 0xFD, 0x08, // resource ID + 0x17, 0x00, 0x00, // resource length (uncompressed) + 0x02, // resource type + 0x17, 0x00, 0x00, // resource length in file + 0x0B) // content type + assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:]) +} + +func TestWriterCompressedSingleBlockResourceCanBeWritten(t *testing.T) { + data := []byte{0x01, 0x02, 0x01, 0x02} + store := serial.NewByteStore() + writer, _ := lgres.NewWriter(store) + resourceWriter, err := writer.CreateResource(resource.ID(0x1122), resource.ContentType(0x0C), true) + assert.Nil(t, err, "no error expected creating resource") + _, _ = resourceWriter.Write(data) + err = writer.Finish() + assert.Nil(t, err, "no error expected finishing") + + result := store.Data() + + var expected []byte + // 0000 0000|0000 0100|0000 0000|0010 0000|0100 0000|0011 1111|1111 1111 + expected = append(expected, + 0x00, 0x04, 0x00, 0x20, 0x40, 0x3F, 0xFF, 0x00, // 14bit words 0x0001 0x0002 0x0100 0x3FFF + trailing 0x00 + // (no bytes) alignment for directory + 0x01, 0x00, // resource count + 0x80, 0x00, 0x00, 0x00, // offset to first resource + 0x22, 0x11, // resource ID + 0x04, 0x00, 0x00, // resource length (uncompressed) + 0x01, // resource type + 0x08, 0x00, 0x00, // resource length in file + 0x0C) // content type + assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:]) +} + +func TestWriterCompressedCompoundResourceCanBeWritten(t *testing.T) { + blockData1 := []byte{0x01, 0x02, 0x01, 0x02} + blockData2 := []byte{0x01, 0x02, 0x01, 0x02} + store := serial.NewByteStore() + writer, _ := lgres.NewWriter(store) + resourceWriter, err := writer.CreateCompoundResource(resource.ID(0x5544), resource.ContentType(0x09), true) + assert.Nil(t, err, "no error expected creating resource") + _, _ = resourceWriter.CreateBlock().Write(blockData1) + _, _ = resourceWriter.CreateBlock().Write(blockData2) + err = writer.Finish() + assert.Nil(t, err, "no error expected finishing") + + result := store.Data() + + var expected []byte + expected = append(expected, + 0x02, 0x00, // number of blocks + 0x0E, 0x00, 0x00, 0x00, // offset to first block + 0x12, 0x00, 0x00, 0x00, // offset to second block + 0x16, 0x00, 0x00, 0x00, // size of resource + 0x00, 0x04, 0x00, 0x20, 0x40, // compressed data, part 1 + 0x01, 0x02, 0x00, 0x0B, 0xFF, 0xF0, 0x00, // compressed data, part 2 + 0x00, 0x00, // alignment for directory + 0x01, 0x00, // resource count + 0x80, 0x00, 0x00, 0x00, // offset to first resource + 0x44, 0x55, // resource ID + 0x16, 0x00, 0x00, // resource length (uncompressed) + 0x03, // resource type + 0x1A, 0x00, 0x00, // resource length in file + 0x09) // content type + assert.Equal(t, expected, result[format.ResourceDirectoryFileOffsetPos+4:]) +} diff --git a/modding/hacked/ss1/resource/lgres/doc.go b/modding/hacked/ss1/resource/lgres/doc.go new file mode 100644 index 0000000..ce11c82 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/doc.go @@ -0,0 +1,4 @@ +/* +Package lgres implements serialization of resources using the DOS format. +*/ +package lgres diff --git a/modding/hacked/ss1/resource/lgres/fixtures_test.go b/modding/hacked/ss1/resource/lgres/fixtures_test.go new file mode 100644 index 0000000..f8a4648 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/fixtures_test.go @@ -0,0 +1,54 @@ +package lgres_test + +import ( + "bytes" + "encoding/binary" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres" + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/format" + "github.com/inkyblackness/hacked/ss1/serial" +) + +func emptyResourceFile() []byte { + buf := bytes.NewBufferString(format.HeaderString) + headerTrailer := make([]byte, format.ResourceDirectoryFileOffsetPos-buf.Len()) + headerTrailer[0] = format.CommentTerminator + + _ = binary.Write(buf, binary.LittleEndian, headerTrailer) + dictionaryOffset := uint32(buf.Len() + 4) + _ = binary.Write(buf, binary.LittleEndian, &dictionaryOffset) + + numberOfResources := uint16(0) + firstResourceOffset := uint32(buf.Len()) + + _ = binary.Write(buf, binary.LittleEndian, &numberOfResources) + _ = binary.Write(buf, binary.LittleEndian, &firstResourceOffset) + + return buf.Bytes() +} + +var exampleResourceIDSingleBlockResource = resource.ID(0x4000) +var exampleResourceIDSingleBlockResourceCompressed = resource.ID(0x1000) +var exampleResourceIDCompoundResource = resource.ID(0x2000) +var exampleResourceIDCompoundResourceCompressed = resource.ID(0x5000) + +func exampleResourceFile() []byte { + store := serial.NewByteStore() + writer, _ := lgres.NewWriter(store) + + resource1, _ := writer.CreateResource(exampleResourceIDSingleBlockResource, resource.ContentType(0x01), false) + _, _ = resource1.Write([]byte{0x01, 0x01, 0x01}) + resource2, _ := writer.CreateResource(exampleResourceIDSingleBlockResourceCompressed, resource.ContentType(0x02), true) + _, _ = resource2.Write([]byte{0x02, 0x02}) + resource3, _ := writer.CreateCompoundResource(exampleResourceIDCompoundResource, resource.ContentType(0x03), false) + _, _ = resource3.CreateBlock().Write([]byte{0x30, 0x30, 0x30, 0x30}) + _, _ = resource3.CreateBlock().Write([]byte{0x31, 0x31, 0x31}) + resource4, _ := writer.CreateCompoundResource(exampleResourceIDCompoundResourceCompressed, resource.ContentType(0x04), true) + _, _ = resource4.CreateBlock().Write([]byte{0x40, 0x40}) + _, _ = resource4.CreateBlock().Write([]byte{0x41, 0x41, 0x41, 0x41}) + _, _ = resource4.CreateBlock().Write([]byte{0x42}) + _ = writer.Finish() + + return store.Data() +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/Compressor.go b/modding/hacked/ss1/resource/lgres/internal/compression/Compressor.go new file mode 100644 index 0000000..b04fd1a --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/Compressor.go @@ -0,0 +1,84 @@ +package compression + +import ( + "io" + + "github.com/inkyblackness/hacked/ss1/serial" +) + +type compressor struct { + coder serial.Coder + writer *WordWriter + + dictBuffer dictEntryBuffer + overtime int + dictionary *dictEntry + dictionarySize int + curEntry *dictEntry +} + +// NewCompressor creates a new compressor instance over a writer. +func NewCompressor(target io.Writer) io.WriteCloser { + coder := serial.NewEncoder(target) + obj := &compressor{ + coder: coder, + writer: NewWordWriter(coder), + dictionary: rootDictEntry(), + dictionarySize: 0, + overtime: 0} + + obj.resetDictionary() + + return obj +} + +func (obj *compressor) resetDictionary() { + obj.dictionarySize = 0 + for i := 0; i < 0x100; i++ { + obj.dictionary.Add(byte(i), Word(i), obj.dictBuffer.entry(Word(i))) + } + obj.curEntry = obj.dictionary +} + +func (obj *compressor) Close() error { + obj.writer.Write(obj.curEntry.key) + obj.writer.Close() + + return obj.coder.FirstError() +} + +func (obj *compressor) Write(p []byte) (n int, err error) { + for _, input := range p { + obj.addByte(input) + } + + return len(p), obj.coder.FirstError() +} + +func (obj *compressor) addByte(value byte) { + nextEntry := obj.curEntry.next[int(value)] + if nextEntry != nil { + obj.curEntry = nextEntry + } else { + obj.writer.Write(obj.curEntry.key) + + key := Word(int(literalLimit) + obj.dictionarySize) + if key < Reset { + obj.curEntry.Add(value, key, obj.dictBuffer.entry(key)) + obj.dictionarySize++ + } else { + obj.onKeySaturation() + } + + obj.curEntry = obj.dictionary.next[value] + } +} + +func (obj *compressor) onKeySaturation() { + obj.overtime++ + if obj.overtime > 1000 { + obj.writer.Write(Reset) + obj.resetDictionary() + obj.overtime = 0 + } +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/Compressor_test.go b/modding/hacked/ss1/resource/lgres/internal/compression/Compressor_test.go new file mode 100644 index 0000000..799a8e0 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/Compressor_test.go @@ -0,0 +1,68 @@ +package compression_test + +import ( + "bytes" + "io" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type CompressorSuite struct { + suite.Suite + store *serial.ByteStore + compressor io.WriteCloser +} + +func TestCompressorSuite(t *testing.T) { + suite.Run(t, new(CompressorSuite)) +} + +func (suite *CompressorSuite) SetupTest() { + suite.store = serial.NewByteStore() + suite.compressor = compression.NewCompressor(suite.store) +} + +func (suite *CompressorSuite) TestWriteCompressesFirstReocurrence() { + suite.givenWrittenData([]byte{0x00, 0x01}) + suite.givenWrittenData([]byte{0x00, 0x01}) + + err := suite.compressor.Close() + assert.Nil(suite.T(), err, "no error expected") + + suite.thenWordsShouldBe(compression.Word(0x0000), compression.Word(0x0001), compression.Word(0x0100)) +} + +func (suite *CompressorSuite) TestWriteCompressesTest1() { + suite.givenWrittenData([]byte{0x00, 0x01, 0x00, 0x02, 0x01, 0x00, 0x01}) + + err := suite.compressor.Close() + assert.Nil(suite.T(), err, "no error expected") + + suite.thenWordsShouldBe( + compression.Word(0x0000), compression.Word(0x0001), compression.Word(0x0000), + compression.Word(0x0002), compression.Word(0x0101), compression.Word(0x0001)) +} + +func (suite *CompressorSuite) givenWrittenData(data []byte) { + written, err := suite.compressor.Write(data) + require.Nil(suite.T(), err, "no error expected writing") + require.Equal(suite.T(), len(data), written, "expected all bytes to be written") +} + +func (suite *CompressorSuite) thenWordsShouldBe(expected ...compression.Word) { + source := bytes.NewReader(suite.store.Data()) + reader := compression.NewWordReader(serial.NewDecoder(source)) + var words []compression.Word + + for read := reader.Read(); read != compression.EndOfStream; read = reader.Read() { + words = append(words, read) + } + + assert.Equal(suite.T(), expected, words) +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/Decompressor.go b/modding/hacked/ss1/resource/lgres/internal/compression/Decompressor.go new file mode 100644 index 0000000..a575975 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/Decompressor.go @@ -0,0 +1,129 @@ +package compression + +import ( + "io" + + "github.com/inkyblackness/hacked/ss1/serial" +) + +type decompressor struct { + coder serial.Coder + reader *WordReader + + isEndOfStream bool + dictBuffer dictEntryBuffer + dictionary *dictEntry + dictionarySize int + lastEntry *dictEntry + lookup []*dictEntry + + scratch []byte + leftover []byte +} + +// NewDecompressor creates a new decompressor instance over a reader. +func NewDecompressor(source io.Reader) io.Reader { + coder := serial.NewDecoder(source) + obj := &decompressor{ + coder: coder, + reader: NewWordReader(coder), + dictionary: rootDictEntry(), + scratch: make([]byte, 1024)} + obj.resetDictionary() + + return obj +} + +func (obj *decompressor) resetDictionary() { + obj.dictionarySize = 0 + obj.lookup = make([]*dictEntry, 1024) + obj.dictionary = rootDictEntry() + for i := 0; i < 0x100; i++ { + entry := obj.dictionary.Add(byte(i), Word(i), obj.dictBuffer.entry(Word(i))) + obj.lookup[Word(i)] = entry + } + obj.lastEntry = obj.dictionary +} + +func (obj *decompressor) Read(p []byte) (n int, err error) { + requested := len(p) + + for n < requested && !obj.isEndOfStream && obj.coder.FirstError() == nil { + n += obj.takeFromLeftover(p[n:]) + if n < requested { + obj.readNextWord() + n += obj.takeFromLeftover(p[n:]) + } + } + err = obj.coder.FirstError() + if err == nil && obj.isEndOfStream { + err = io.EOF + } + + return +} + +func (obj *decompressor) takeFromLeftover(target []byte) (provided int) { + requested := len(target) + available := len(obj.leftover) + + if available > 0 && requested > 0 { + provided = available + if provided > requested { + provided = requested + } + copy(target[0:provided], obj.leftover) + obj.leftover = obj.leftover[provided:] + } + + return +} + +func (obj *decompressor) readNextWord() { + nextWord := obj.reader.Read() + + if obj.lastEntry.depth > len(obj.scratch) { + obj.scratch = make([]byte, len(obj.scratch)+1024) + } + obj.leftover = obj.scratch[:obj.lastEntry.depth] + obj.lastEntry.Data(obj.leftover) + switch nextWord { + case EndOfStream: + obj.isEndOfStream = true + case Reset: + obj.resetDictionary() + default: + var nextEntry *dictEntry + if int(nextWord) < len(obj.lookup) { + nextEntry = obj.lookup[int(nextWord)] + } + + switch { + case nextEntry != nil: + if obj.lastEntry.depth > 0 { + obj.addToDictionary(nextEntry.FirstByte()) + } + obj.lastEntry = nextEntry + case nextWord >= literalLimit: + nextValue := obj.lastEntry.FirstByte() + obj.addToDictionary(nextValue) + obj.lastEntry = obj.lastEntry.next[nextValue] + default: + nextValue := byte(nextWord) + obj.addToDictionary(nextValue) + obj.lastEntry = obj.dictionary.next[nextValue] + } + } +} + +func (obj *decompressor) addToDictionary(value byte) { + key := Word(int(literalLimit) + obj.dictionarySize) + nextEntry := obj.lastEntry.Add(value, key, obj.dictBuffer.entry(key)) + if int(key) >= len(obj.lookup) { + newLookup := make([]*dictEntry, len(obj.lookup)+1024) + copy(newLookup, obj.lookup) + obj.lookup = newLookup + } + obj.lookup[key] = nextEntry + obj.dictionarySize++ +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/Decompressor_test.go b/modding/hacked/ss1/resource/lgres/internal/compression/Decompressor_test.go new file mode 100644 index 0000000..e99e554 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/Decompressor_test.go @@ -0,0 +1,125 @@ +package compression_test + +import ( + "bytes" + "errors" + "fmt" + "io" + "math/rand" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/serial" +) + +type DecompressorSuite struct { + suite.Suite + store *serial.ByteStore + compressor io.WriteCloser +} + +func TestDecompressorSuite(t *testing.T) { + suite.Run(t, new(DecompressorSuite)) +} + +func (suite *DecompressorSuite) SetupTest() { +} + +func (suite *DecompressorSuite) TestDecompressTest1() { + input := []byte{0x00, 0x01, 0x00, 0x01} + + suite.verify(input) +} + +func (suite *DecompressorSuite) TestDecompressTest2() { + input := []byte{0x00, 0x01, 0x00, 0x01, 0x00, 0x01} + + suite.verify(input) +} + +func (suite *DecompressorSuite) TestDecompressTest3() { + suite.verify([]byte{}) +} + +func (suite *DecompressorSuite) TestDecompressTest4() { + input := []byte{0x00, 0x01, 0x00, 0x02, 0x01, 0x00, 0x01} + + suite.verify(input) +} + +func (suite *DecompressorSuite) TestDecompressTest5() { + input := []byte{0x00, 0x01, 0x00, 0x02, 0x01, 0x00, 0x01, 0x02, 0x01, 0x02, 0x01, 0x00, 0x01, 0x02} + + suite.verify(input) +} + +func (suite *DecompressorSuite) TestDecompressTestRandom() { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + + for testCase := 0; testCase < 100; testCase++ { + input := make([]byte, r.Intn(1024)) + for i := 0; i < len(input); i++ { + input[i] = byte(r.Intn(256)) + } + suite.verify(input) + } +} + +func (suite *DecompressorSuite) TestDecompressHandlesDictionaryResets() { + suite.writeWords(0x0001, 0x0002, 0x0100, compression.Reset, 0x0003, 0x0004, 0x0100, compression.EndOfStream) + + suite.verifyOutput([]byte{0x01, 0x02, 0x01, 0x02, 0x03, 0x04, 0x03, 0x04}) +} + +func (suite *DecompressorSuite) TestDecompressHandlesSelfReferencingWords() { + suite.writeWords(0x0001, 0x0002, 0x0101, compression.EndOfStream) + + suite.verifyOutput([]byte{0x01, 0x02, 0x02, 0x02}) +} + +func (suite *DecompressorSuite) writeWords(values ...compression.Word) { + suite.store = serial.NewByteStore() + coder := serial.NewEncoder(suite.store) + writer := compression.NewWordWriter(coder) + + for _, value := range values { + writer.Write(value) + } + writer.Close() +} + +func (suite *DecompressorSuite) verify(input []byte) { + suite.store = serial.NewByteStore() + suite.compressor = compression.NewCompressor(serial.NewEncoder(suite.store)) + + n, err := suite.compressor.Write(input) + assert.Nil(suite.T(), err, "no error expected writing") + assert.Equal(suite.T(), len(input), n, "invalid number written") + err = suite.compressor.Close() + assert.Nil(suite.T(), err, "no error expected closing") + + suite.verifyOutput(input) +} + +func (suite *DecompressorSuite) verifyOutput(expected []byte) { + output := suite.buffer(len(expected)) + source := bytes.NewReader(suite.store.Data()) + decompressor := compression.NewDecompressor(source) + read, err := decompressor.Read(output) + + assert.True(suite.T(), (err == nil) || errors.Is(err, io.EOF), fmt.Sprintf("unexpected error: %v", err)) + assert.Equal(suite.T(), len(output), read, "unexpected number of bytes read") + assert.Equal(suite.T(), expected, output) +} + +func (suite *DecompressorSuite) buffer(byteCount int) []byte { + result := make([]byte, byteCount) + for i := range result { + result[i] = 0xFF + } + return result +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/DictEntry.go b/modding/hacked/ss1/resource/lgres/internal/compression/DictEntry.go new file mode 100644 index 0000000..78a9570 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/DictEntry.go @@ -0,0 +1,50 @@ +package compression + +type dictEntry struct { + prev *dictEntry + depth int + + next [256]*dictEntry + + key Word + value byte + first byte + used bool +} + +func rootDictEntry() *dictEntry { + return &dictEntry{prev: nil, depth: 0, value: 0x00, key: Reset} +} + +var emptyList [256]*dictEntry + +func (entry *dictEntry) Add(value byte, key Word, newEntry *dictEntry) *dictEntry { + newEntry.prev = entry + newEntry.depth = entry.depth + 1 + newEntry.value = value + newEntry.key = key + newEntry.first = entry.first + if newEntry.used { + copy(newEntry.next[:], emptyList[:]) + } + newEntry.used = true + + entry.next[value] = newEntry + if entry.depth == 0 { + newEntry.first = value + } + + return newEntry +} + +func (entry *dictEntry) Data(bytes []byte) { + cur := entry + for i := entry.depth - 1; i >= 0; i-- { + bytes[i] = cur.value + cur = cur.prev + } +} + +func (entry *dictEntry) FirstByte() byte { + return entry.first +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/DictEntryBuffer.go b/modding/hacked/ss1/resource/lgres/internal/compression/DictEntryBuffer.go new file mode 100644 index 0000000..d9b80e9 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/DictEntryBuffer.go @@ -0,0 +1,21 @@ +package compression + +// dictEntryBucketSizeBits governs how big one bucket in the buffer is. +const dictEntryBucketSizeBits = 8 + +type dictEntryBucket [1 << dictEntryBucketSizeBits]dictEntry + +type dictEntryBuffer struct { + buckets []*dictEntryBucket +} + +func (buffer *dictEntryBuffer) entry(key Word) *dictEntry { + bucketIndex := int(key) >> dictEntryBucketSizeBits + index := int(key & ((1 << dictEntryBucketSizeBits) - 1)) + + for bucketIndex >= len(buffer.buckets) { + buffer.buckets = append(buffer.buckets, new(dictEntryBucket)) + } + + return &buffer.buckets[bucketIndex][index] +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/Word.go b/modding/hacked/ss1/resource/lgres/internal/compression/Word.go new file mode 100644 index 0000000..01bd105 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/Word.go @@ -0,0 +1,14 @@ +package compression + +// Word is a compression stream entry. +type Word uint16 + +const ( + bitsPerWord = uint(14) + + // EndOfStream indicates the last word in the compressed stream. + EndOfStream = Word(0x3FFF) + // Reset indicates a reset of the dictionary. + Reset = Word(0x3FFE) + literalLimit = Word(0x0100) +) diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/WordReader.go b/modding/hacked/ss1/resource/lgres/internal/compression/WordReader.go new file mode 100644 index 0000000..adf9b22 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/WordReader.go @@ -0,0 +1,41 @@ +package compression + +import "github.com/inkyblackness/hacked/ss1/serial" + +// WordReader provides word instances from a serialized stream. +type WordReader struct { + coder serial.Coder + + buffer byte + bufferBitsAvailable uint +} + +// NewWordReader returns a new instance. +func NewWordReader(coder serial.Coder) *WordReader { + reader := &WordReader{coder: coder, buffer: 0x00, bufferBitsAvailable: 0} + + return reader +} + +// Read returns the next word from the stream. +func (reader *WordReader) Read() (value Word) { + remaining := bitsPerWord + + for remaining > reader.bufferBitsAvailable { + value = Word((uint(value) << reader.bufferBitsAvailable) | uint(reader.buffer)) + remaining -= reader.bufferBitsAvailable + + reader.bufferByte() + } + + value = Word((uint(value) << remaining) | uint(reader.buffer)>>(8-remaining)) + reader.buffer &= 1<<(8-remaining) - 1 + reader.bufferBitsAvailable -= remaining + + return +} + +func (reader *WordReader) bufferByte() { + reader.coder.Code(&reader.buffer) + reader.bufferBitsAvailable = 8 +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/WordReader_test.go b/modding/hacked/ss1/resource/lgres/internal/compression/WordReader_test.go new file mode 100644 index 0000000..68228c0 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/WordReader_test.go @@ -0,0 +1,56 @@ +package compression_test + +import ( + "bytes" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type WordReaderSuite struct { + suite.Suite + reader *compression.WordReader +} + +func TestWordReaderSuite(t *testing.T) { + suite.Run(t, new(WordReaderSuite)) +} + +func (suite *WordReaderSuite) TestReadCanReadFirstWord() { + suite.givenReaderFrom() + + assert.Equal(suite.T(), compression.EndOfStream, suite.reader.Read()) +} + +func (suite *WordReaderSuite) TestReadCanReadSeveralWords() { + input := []compression.Word{ + compression.Word(0x3FFF), compression.Word(0x0000), compression.Word(0x3FFF), + compression.Word(0x0000), compression.Word(0x2001), compression.Word(0x1234), + } + suite.givenReaderFrom(input...) + + output := make([]compression.Word, len(input)+1) + for i := 0; i < len(output); i++ { + output[i] = suite.reader.Read() + } + + assert.Equal(suite.T(), append(input, compression.EndOfStream), output) +} + +func (suite *WordReaderSuite) givenReaderFrom(words ...compression.Word) { + store := serial.NewByteStore() + encoder := serial.NewEncoder(store) + writer := compression.NewWordWriter(encoder) + + for _, value := range words { + writer.Write(value) + } + writer.Close() + + source := bytes.NewReader(store.Data()) + suite.reader = compression.NewWordReader(serial.NewDecoder(source)) +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/WordWriter.go b/modding/hacked/ss1/resource/lgres/internal/compression/WordWriter.go new file mode 100644 index 0000000..da0936f --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/WordWriter.go @@ -0,0 +1,59 @@ +package compression + +import "github.com/inkyblackness/hacked/ss1/serial" + +// WordWriter serializes a stream of word entries. +type WordWriter struct { + coder serial.Coder + + bufferedBits uint + scratch uint32 + + outBuffer [1024]byte + outUsed int +} + +// NewWordWriter returns a new instance. +func NewWordWriter(coder serial.Coder) *WordWriter { + writer := &WordWriter{coder: coder, bufferedBits: 0, scratch: 0} + + return writer +} + +// Close finishes the stream of words. +func (writer *WordWriter) Close() { + writer.Write(EndOfStream) + if writer.bufferedBits > 0 { + writer.writeByte(byte(writer.scratch >> 16)) + } + writer.writeByte(byte(0x00)) + writer.flushBuffer() +} + +// Write adds the given word to the stream. +func (writer *WordWriter) Write(value Word) { + writer.scratch |= uint32(value) << ((16 - bitsPerWord) + (8 - writer.bufferedBits)) + writer.bufferedBits += bitsPerWord + writer.writeByte(byte(writer.scratch >> 16)) + writer.scratch <<= 8 + writer.bufferedBits -= 8 + + if writer.bufferedBits >= 8 { + writer.writeByte(byte(writer.scratch >> 16)) + writer.scratch <<= 8 + writer.bufferedBits -= 8 + } +} + +func (writer *WordWriter) writeByte(value byte) { + writer.outBuffer[writer.outUsed] = value + writer.outUsed++ + if writer.outUsed >= len(writer.outBuffer) { + writer.flushBuffer() + } +} + +func (writer *WordWriter) flushBuffer() { + writer.coder.Code(writer.outBuffer[:writer.outUsed]) + writer.outUsed = 0 +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/WordWriter_test.go b/modding/hacked/ss1/resource/lgres/internal/compression/WordWriter_test.go new file mode 100644 index 0000000..8a62c6a --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/WordWriter_test.go @@ -0,0 +1,49 @@ +package compression_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type WordWriterSuite struct { + suite.Suite + writer *compression.WordWriter + store *serial.ByteStore +} + +func TestWordWriterSuite(t *testing.T) { + suite.Run(t, new(WordWriterSuite)) +} + +func (suite *WordWriterSuite) SetupTest() { + suite.store = serial.NewByteStore() + coder := serial.NewEncoder(suite.store) + suite.writer = compression.NewWordWriter(coder) +} + +func (suite *WordWriterSuite) TestCloseWritesEndOfStreamMarkerAndTrailingZeroByte() { + suite.writer.Close() + + assert.Equal(suite.T(), []byte{0xFF, 0xFC, 0x00}, suite.store.Data()) +} + +func (suite *WordWriterSuite) TestCloseWritesRemainderOnlyIfNotEmpty() { + suite.writer.Write(compression.Word(0x0000)) + suite.writer.Write(compression.Word(0x0000)) + suite.writer.Write(compression.Word(0x0000)) + suite.writer.Close() + + assert.Equal(suite.T(), []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, 0xFF, 0x00}, suite.store.Data()) +} + +func (suite *WordWriterSuite) TestWriteAndCloseLinesUpBits() { + suite.writer.Write(compression.Word(0x1FFE)) // 0111111 1111110 + suite.writer.Close() // 1111111 1111111 + + assert.Equal(suite.T(), []byte{0x7F, 0xFB, 0xFF, 0xF0, 0x00}, suite.store.Data()) +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/benchmark_test.go b/modding/hacked/ss1/resource/lgres/internal/compression/benchmark_test.go new file mode 100644 index 0000000..85a07d0 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/benchmark_test.go @@ -0,0 +1,119 @@ +package compression_test + +import ( + "flag" + "io" + "math/rand" + "os" + "runtime/pprof" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource/lgres/internal/compression" + "github.com/inkyblackness/hacked/ss1/serial" +) + +// To run a profile, run it with the following command: +// go test -bench . -args -cpuprofile=prof +var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file") + +func rawData(size int) []byte { + data := make([]byte, size) + rand.Read(data) // nolint:gas + return data +} + +func initProfiling(b *testing.B, nameSuffix string) func() { + b.Helper() + flag.Parse() + if *cpuprofile != "" { + f, err := os.Create(*cpuprofile + "-" + b.Name() + nameSuffix) + if err != nil { + b.Fatal(err) + } + _ = pprof.StartCPUProfile(f) + return func() { pprof.StopCPUProfile() } + } + return func() {} +} + +func BenchmarkRawDataStorage(b *testing.B) { + profileStop := initProfiling(b, "") + defer profileStop() + data := rawData(1024 * 1024) + b.ResetTimer() + for run := 0; run < b.N; run++ { + encoder := serial.NewEncoder(serial.NewByteStore()) + _, _ = encoder.Write(data) + } +} + +func benchmarkCompression(b *testing.B, size int, nameSuffix string) { + b.Helper() + profileStop := initProfiling(b, nameSuffix) + defer profileStop() + data := rawData(size) + b.ResetTimer() + for run := 0; run < b.N; run++ { + compressor := compression.NewCompressor(serial.NewByteStore()) + _, _ = compressor.Write(data) + _ = compressor.Close() + } +} + +func BenchmarkCompression1KB(b *testing.B) { + benchmarkCompression(b, 1024, "1KB") +} + +func BenchmarkCompression16KB(b *testing.B) { + benchmarkCompression(b, 1024*16, "16KB") +} + +func BenchmarkCompression128KB(b *testing.B) { + benchmarkCompression(b, 1024*128, "128KB") +} + +func BenchmarkCompression512KB(b *testing.B) { + benchmarkCompression(b, 1024*512, "512KB") +} + +func BenchmarkCompression1024KB(b *testing.B) { + benchmarkCompression(b, 1024*1024, "1024KB") +} + +func benchmarkCompressionDecompression(b *testing.B, size int, nameSuffix string) { + b.Helper() + profileStop := initProfiling(b, nameSuffix) + defer profileStop() + data := rawData(size) + output := make([]byte, len(data)) + b.ResetTimer() + for run := 0; run < b.N; run++ { + store := serial.NewByteStore() + compressor := compression.NewCompressor(store) + _, _ = compressor.Write(data) + _ = compressor.Close() + _, _ = store.Seek(0, io.SeekStart) + decompressor := compression.NewDecompressor(store) + _, _ = decompressor.Read(output) + } +} + +func BenchmarkCompressionDecompression1KB(b *testing.B) { + benchmarkCompressionDecompression(b, 1024, "1KB") +} + +func BenchmarkCompressionDecompression16KB(b *testing.B) { + benchmarkCompressionDecompression(b, 1024*16, "16KB") +} + +func BenchmarkCompressionDecompression128KB(b *testing.B) { + benchmarkCompressionDecompression(b, 1024*128, "128KB") +} + +func BenchmarkCompressionDecompression512KB(b *testing.B) { + benchmarkCompressionDecompression(b, 1024*512, "512KB") +} + +func BenchmarkCompressionDecompression1024KB(b *testing.B) { + benchmarkCompressionDecompression(b, 1024*1024, "1024KB") +} diff --git a/modding/hacked/ss1/resource/lgres/internal/compression/doc.go b/modding/hacked/ss1/resource/lgres/internal/compression/doc.go new file mode 100644 index 0000000..9ec57e5 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/compression/doc.go @@ -0,0 +1,4 @@ +/* +Package compression implements the LG resource file compression algorithm. +*/ +package compression diff --git a/modding/hacked/ss1/resource/lgres/internal/format/Constants.go b/modding/hacked/ss1/resource/lgres/internal/format/Constants.go new file mode 100644 index 0000000..ac7e543 --- /dev/null +++ b/modding/hacked/ss1/resource/lgres/internal/format/Constants.go @@ -0,0 +1,17 @@ +package format + +const ( + // HeaderString is the start of an LG Res file. + HeaderString = "LG Res File v2\r\n" + // CommentTerminator terminates the header string. + CommentTerminator = byte(0x1A) + // ResourceDirectoryFileOffsetPos is the byte offset for the pointer to the directory. + ResourceDirectoryFileOffsetPos = 0x7C + // BoundarySize is the byte interval at which entries must be placed. + BoundarySize = 4 + + // ResourceTypeFlagCompressed indicates a compressed resource. + ResourceTypeFlagCompressed = byte(0x01) + // ResourceTypeFlagCompound indicates a compound resource. + ResourceTypeFlagCompound = byte(0x02) +) diff --git a/modding/hacked/ss1/serial/ByteStore.go b/modding/hacked/ss1/serial/ByteStore.go new file mode 100644 index 0000000..854af52 --- /dev/null +++ b/modding/hacked/ss1/serial/ByteStore.go @@ -0,0 +1,89 @@ +package serial + +import ( + "io" +) + +const bufferCapacityIncrement = 1024 * 1024 + +// ByteStore is implementing a ReadWriteSeeker storing data in memory. +type ByteStore struct { + data []byte + offset int +} + +// NewByteStore returns a new byte store instance. +func NewByteStore() *ByteStore { + return NewByteStoreFromData(make([]byte, 0, bufferCapacityIncrement)) +} + +// NewByteStoreFromData returns a new ByteStore instance wrapping the provided data. +func NewByteStoreFromData(data []byte) *ByteStore { + store := &ByteStore{ + data: data, + offset: 0, + } + + return store +} + +// Data returns the current data buffer. +func (store *ByteStore) Data() []byte { + return store.data +} + +// Seek implements the Seeker interface. +func (store *ByteStore) Seek(offset int64, whence int) (int64, error) { + switch whence { + case 0: + store.offset = int(offset) + case 1: + store.offset += int(offset) + case 2: + store.offset = len(store.data) + int(offset) + } + + return int64(store.offset), nil +} + +// Read implements the Reader interface. +func (store *ByteStore) Read(p []byte) (n int, err error) { + size := len(p) + n = len(store.data) - store.offset + if n > size { + n = size + } + if n < size && size > 0 { + err = io.EOF + } + copy(p, store.data[store.offset:store.offset+n]) + store.offset += n + + return +} + +// Write implements the Writer interface. +func (store *ByteStore) Write(p []byte) (n int, err error) { + size := len(p) + store.ensureAvailable(size) + copy(store.data[store.offset:], p) + store.offset += size + + return size, nil +} + +func (store *ByteStore) ensureAvailable(size int) { + expected := store.offset + size + available := len(store.data) + + if expected > available { + if expected > cap(store.data) { + old := store.data + + store.data = make([]byte, expected, expected+bufferCapacityIncrement) + copy(store.data, old) + } else { + store.data = append(store.data, make([]byte, expected-available)...) + } + } +} diff --git a/modding/hacked/ss1/serial/Codable.go b/modding/hacked/ss1/serial/Codable.go new file mode 100644 index 0000000..3e96579 --- /dev/null +++ b/modding/hacked/ss1/serial/Codable.go @@ -0,0 +1,7 @@ +package serial + +// Codable is something than can be serialized with a Coder. +type Codable interface { + // Code requests to serialize the Codable via the provided Coder. + Code(coder Coder) +} diff --git a/modding/hacked/ss1/serial/Coder.go b/modding/hacked/ss1/serial/Coder.go new file mode 100644 index 0000000..acb2130 --- /dev/null +++ b/modding/hacked/ss1/serial/Coder.go @@ -0,0 +1,12 @@ +package serial + +// Coder represents an Encoder/Decoder for binary data. +type Coder interface { + // FirstError returns the first error this coder encountered. + FirstError() error + + // Code serializes the given value. + // Any error state will be provided via FirstError(). Code will do nothing + // if the coder is already in error state. + Code(value interface{}) +} diff --git a/modding/hacked/ss1/serial/Decoder.go b/modding/hacked/ss1/serial/Decoder.go new file mode 100644 index 0000000..f44d4fd --- /dev/null +++ b/modding/hacked/ss1/serial/Decoder.go @@ -0,0 +1,58 @@ +package serial + +import ( + "encoding/binary" + "errors" + "io" +) + +// Decoder is for decoding from a reader. +type Decoder struct { + source io.Reader + firstError error + offset uint32 +} + +// NewDecoder creates a new Decoder from given source. +func NewDecoder(source io.Reader) *Decoder { + return &Decoder{source: source} +} + +// FirstError returns the error this Decoder encountered the first time. +func (coder *Decoder) FirstError() error { + return coder.firstError +} + +// CurPos returns the current decoding position, in bytes. +func (coder *Decoder) CurPos() uint32 { + return coder.offset +} + +// Code serializes the given value in little endian format using binary.Read(). +func (coder *Decoder) Code(value interface{}) { + if coder.firstError != nil { + return + } + if codable, isCodable := value.(Codable); isCodable { + codable.Code(coder) + } else { + coder.firstError = binary.Read(coder, binary.LittleEndian, value) + } +} + +// Read reads the next bytes from the underlying source. +func (coder *Decoder) Read(data []byte) (read int, err error) { + if coder.firstError != nil { + return 0, coder.firstError + } + read, err = coder.source.Read(data) + coder.offset += uint32(read) + + isErrEOF := errors.Is(err, io.EOF) + expectedAmountReturned := read == len(data) + errorCanBeIgnored := isErrEOF && expectedAmountReturned + if (coder.firstError == nil) && !errorCanBeIgnored { + coder.firstError = err + } + return +} diff --git a/modding/hacked/ss1/serial/Decoder_test.go b/modding/hacked/ss1/serial/Decoder_test.go new file mode 100644 index 0000000..f9b8adf --- /dev/null +++ b/modding/hacked/ss1/serial/Decoder_test.go @@ -0,0 +1,138 @@ +package serial_test + +import ( + "bytes" + "io" + "testing" + + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type DecoderSuite struct { + suite.Suite + errorBuf *errorBuffer + coder *serial.Decoder +} + +func TestDecoderSuite(t *testing.T) { + suite.Run(t, new(DecoderSuite)) +} + +func (suite *DecoderSuite) SetupTest() { + suite.errorBuf = nil + suite.coder = nil +} + +func (suite *DecoderSuite) TestImplementsCoderInterface() { + instance := interface{}(suite.coder) + _, ok := instance.(serial.Coder) + assert.True(suite.T(), ok) +} + +func (suite *DecoderSuite) TestCodeUint32() { + suite.whenDecodingFrom([]byte{0x78, 0x56, 0x34, 0x12}) + + var value uint32 + suite.coder.Code(&value) + + assert.Equal(suite.T(), uint32(0x12345678), value) +} + +func (suite *DecoderSuite) TestCodeUint16() { + suite.whenDecodingFrom([]byte{0x45, 0x23}) + + var value uint16 + suite.coder.Code(&value) + + assert.Equal(suite.T(), uint16(0x2345), value) +} + +func (suite *DecoderSuite) TestCodeByte() { + suite.whenDecodingFrom([]byte{0xAB}) + + var value byte + suite.coder.Code(&value) + + assert.Equal(suite.T(), byte(0xAB), value) +} + +func (suite *DecoderSuite) TestCodeByteSlice() { + suite.whenDecodingFrom([]byte{0x78, 0x12, 0x34}) + + value := make([]byte, 3) + suite.coder.Code(value) + + assert.Equal(suite.T(), []byte{0x78, 0x12, 0x34}, value) +} + +func (suite *DecoderSuite) TestFirstErrorReturnsFirstError() { + suite.whenDecodingWithErrors() + + data := uint32(0) + suite.coder.Code(&data) + suite.errorBuf.errorOnNextCall = true + suite.coder.Code(uint16(0)) + + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 2") +} + +func (suite *DecoderSuite) TestFirstErrorIgnoresFurtherErrors() { + suite.whenDecodingWithErrors() + + suite.errorBuf.errorOnNextCall = true + suite.coder.Code(uint32(0)) + suite.errorBuf.errorOnNextCall = true + suite.coder.Code(uint32(0)) + + assert.Equal(suite.T(), suite.errorBuf.callCounter, 1) + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 1") +} + +func (suite *DecoderSuite) TestImplementsReaderInterface() { + instance := interface{}(suite.coder) + _, ok := instance.(io.Reader) + assert.True(suite.T(), ok) +} + +func (suite *DecoderSuite) TestReadFromReader() { + suite.whenDecodingFrom([]byte{0x78, 0x12, 0x34}) + + value := make([]byte, 3) + read, err := suite.coder.Read(value) + + assert.Nil(suite.T(), err, "no error expected") + assert.Equal(suite.T(), 3, read, "unexpected length read") + assert.Equal(suite.T(), []byte{0x78, 0x12, 0x34}, value) +} + +func (suite *DecoderSuite) TestReadHandlesErrors() { + suite.whenDecodingWithErrors() + + suite.errorBuf.errorOnNextCall = true + _, _ = suite.coder.Read(make([]byte, 3)) + _, _ = suite.coder.Read(make([]byte, 5)) + assert.Equal(suite.T(), 1, suite.errorBuf.callCounter) + assert.NotNil(suite.T(), suite.coder.FirstError()) +} + +func (suite *DecoderSuite) TestCodeWithCodable() { + codable := new(MockedCodable) + + suite.whenDecodingFrom([]byte{0x78, 0x12, 0x34}) + suite.coder.Code(codable) + + assert.Equal(suite.T(), suite.coder, codable.calledCoder, "Should be called with coder") +} + +func (suite *DecoderSuite) whenDecodingFrom(data []byte) { + source := bytes.NewReader(data) + suite.coder = serial.NewDecoder(source) +} + +func (suite *DecoderSuite) whenDecodingWithErrors() { + suite.errorBuf = new(errorBuffer) + suite.coder = serial.NewDecoder(suite.errorBuf) +} diff --git a/modding/hacked/ss1/serial/Encoder.go b/modding/hacked/ss1/serial/Encoder.go new file mode 100644 index 0000000..622aa67 --- /dev/null +++ b/modding/hacked/ss1/serial/Encoder.go @@ -0,0 +1,56 @@ +package serial + +import ( + "encoding/binary" + "io" +) + +// Encoder implements the Coder interface to write to a writer. +// It also implements the Writer interface. +type Encoder struct { + target io.Writer + firstError error + offset uint32 +} + +// NewEncoder creates and returns a fresh Encoder. +func NewEncoder(target io.Writer) *Encoder { + return &Encoder{target: target} +} + +// FirstError returns the error this Encoder encountered the first time. +func (coder *Encoder) FirstError() error { + return coder.firstError +} + +// CurPos returns the current encoding position, in bytes. +func (coder *Encoder) CurPos() uint32 { + return coder.offset +} + +// Code serializes the given value in little endian format using binary.Write(). +func (coder *Encoder) Code(value interface{}) { + if coder.firstError != nil { + return + } + if codable, isCodable := value.(Codable); isCodable { + codable.Code(coder) + } else { + coder.firstError = binary.Write(coder, binary.LittleEndian, value) + } +} + +// Write serializes the given data to the contained writer. +// If the encoder is in the error state, the result of FirstError() is +// returned and nothing else is done. +func (coder *Encoder) Write(data []byte) (written int, err error) { + if coder.firstError != nil { + return 0, coder.firstError + } + written, err = coder.target.Write(data) + coder.offset += uint32(written) + if coder.firstError == nil { + coder.firstError = err + } + return +} diff --git a/modding/hacked/ss1/serial/Encoder_test.go b/modding/hacked/ss1/serial/Encoder_test.go new file mode 100644 index 0000000..b6530b0 --- /dev/null +++ b/modding/hacked/ss1/serial/Encoder_test.go @@ -0,0 +1,157 @@ +package serial_test + +import ( + "io" + "testing" + + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type EncoderSuite struct { + suite.Suite + store *serial.ByteStore + coder *serial.Encoder +} + +func TestEncoderSuite(t *testing.T) { + suite.Run(t, new(EncoderSuite)) +} + +func (suite *EncoderSuite) SetupTest() { + suite.store = serial.NewByteStore() + suite.coder = serial.NewEncoder(suite.store) +} + +func (suite *EncoderSuite) TestImplementsCoderInterface() { + instance := interface{}(suite.coder) + _, ok := instance.(serial.Coder) + assert.True(suite.T(), ok) +} + +func (suite *EncoderSuite) TestDataOnEmptyEncoderReturnsEmptyArray() { + suite.verifyData(make([]byte, 0)) +} + +func (suite *EncoderSuite) TestCodeUint32Pointer() { + var value uint32 = 0x12345678 + suite.verifyCodedData(&value, []byte{0x78, 0x56, 0x34, 0x12}) +} + +func (suite *EncoderSuite) TestCodeUint32() { + var value uint32 = 0x12345678 + suite.verifyCodedData(value, []byte{0x78, 0x56, 0x34, 0x12}) +} + +func (suite *EncoderSuite) TestCodeUint16Pointer() { + var value uint16 = 0x3456 + suite.verifyCodedData(&value, []byte{0x56, 0x34}) +} + +func (suite *EncoderSuite) TestCodeUint16() { + var value uint16 = 0x3456 + suite.verifyCodedData(value, []byte{0x56, 0x34}) +} + +func (suite *EncoderSuite) TestCodeBytePointer() { + var value byte = 0x42 + suite.verifyCodedData(&value, []byte{0x42}) +} + +func (suite *EncoderSuite) TestCodeByte() { + var value byte = 0x42 + suite.verifyCodedData(value, []byte{0x42}) +} + +func (suite *EncoderSuite) TestCodeByteSlice() { + value := []byte{0x01, 0x02, 0x03} + suite.verifyCodedData(value, []byte{0x01, 0x02, 0x03}) +} + +func (suite *EncoderSuite) TestFirstErrorByCode() { + var target errorBuffer + suite.coder = serial.NewEncoder(&target) + suite.coder.Code(uint16(0)) + target.errorOnNextCall = true + suite.coder.Code(uint32(0)) + + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 2") +} + +func (suite *EncoderSuite) TestCodeDoesNothingOnPreviousError() { + var target errorBuffer + suite.coder = serial.NewEncoder(&target) + target.errorOnNextCall = true + suite.coder.Code(uint32(0)) + target.errorOnNextCall = true + suite.coder.Code(uint32(0)) + + assert.Equal(suite.T(), target.callCounter, 1) + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 1") +} + +func (suite *EncoderSuite) TestImplementsWriterInterface() { + instance := interface{}(suite.coder) + _, ok := instance.(io.Writer) + assert.True(suite.T(), ok) +} + +func (suite *EncoderSuite) TestWriteToWriter() { + written, err := suite.coder.Write([]byte{0x0A, 0x0B, 0x0C}) + assert.Nil(suite.T(), err, "no error expected") + assert.Equal(suite.T(), 3, written, "unexpected length written") + suite.verifyData([]byte{0x0A, 0x0B, 0x0C}) +} + +func (suite *EncoderSuite) TestWriteDoesNothingOnPreviousError() { + var target errorBuffer + suite.coder = serial.NewEncoder(&target) + target.errorOnNextCall = true + suite.coder.Code(uint32(0)) + _, _ = suite.coder.Write([]byte{0x0A, 0x0B, 0x0C}) + + assert.Equal(suite.T(), target.callCounter, 1) +} + +func (suite *EncoderSuite) TestWriteReturnsFirstErrorOnPreviousError() { + var target errorBuffer + suite.coder = serial.NewEncoder(&target) + target.errorOnNextCall = true + suite.coder.Code(uint32(0)) + n, err := suite.coder.Write([]byte{0x0A, 0x0B, 0x0C}) + + assert.Equal(suite.T(), 0, n, "Should return zero written bytes") + assert.EqualError(suite.T(), err, "errorBuffer on call number 1") +} + +func (suite *EncoderSuite) TestWriteOnErrorCase() { + var target errorBuffer + suite.coder = serial.NewEncoder(&target) + target.errorOnNextCall = true + target.errorByteCount = 2 + n, err := suite.coder.Write([]byte{0x0A, 0x0B, 0x0C}) + + assert.Equal(suite.T(), 2, n, "Should return count of successfully written bytes") + assert.EqualError(suite.T(), err, "errorBuffer on call number 1") + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 1", "should set first error") +} + +func (suite *EncoderSuite) TestCodeWithCodable() { + codable := new(MockedCodable) + suite.coder.Code(codable) + + assert.Equal(suite.T(), suite.coder, codable.calledCoder, "Should be called with coder") +} + +func (suite *EncoderSuite) verifyData(expected []byte) { + assert.Nil(suite.T(), suite.coder.FirstError()) + result := suite.store.Data() + assert.Equal(suite.T(), expected, result) +} + +func (suite *EncoderSuite) verifyCodedData(value interface{}, expected []byte) { + suite.coder.Code(value) + suite.verifyData(expected) +} diff --git a/modding/hacked/ss1/serial/MockedCodable_test.go b/modding/hacked/ss1/serial/MockedCodable_test.go new file mode 100644 index 0000000..f43c4ac --- /dev/null +++ b/modding/hacked/ss1/serial/MockedCodable_test.go @@ -0,0 +1,11 @@ +package serial_test + +import "github.com/inkyblackness/hacked/ss1/serial" + +type MockedCodable struct { + calledCoder serial.Coder +} + +func (codable *MockedCodable) Code(coder serial.Coder) { + codable.calledCoder = coder +} diff --git a/modding/hacked/ss1/serial/Positioner.go b/modding/hacked/ss1/serial/Positioner.go new file mode 100644 index 0000000..1b161b8 --- /dev/null +++ b/modding/hacked/ss1/serial/Positioner.go @@ -0,0 +1,9 @@ +package serial + +// Positioner specifies a type that knows about a position which can be modified. +type Positioner interface { + // CurPos gets the current position in the data. + CurPos() uint32 + // SetCurPos sets the current position in the data. + SetCurPos(offset uint32) +} diff --git a/modding/hacked/ss1/serial/PositioningCoder.go b/modding/hacked/ss1/serial/PositioningCoder.go new file mode 100644 index 0000000..2a976bd --- /dev/null +++ b/modding/hacked/ss1/serial/PositioningCoder.go @@ -0,0 +1,9 @@ +package serial + +// PositioningCoder is a coder that also knows about positioning. +// Any error state regarding repositioning will be provided via FirstError(). +// SetCurPos will do nothing if the coder is already in error state. +type PositioningCoder interface { + Positioner + Coder +} diff --git a/modding/hacked/ss1/serial/PositioningDecoder.go b/modding/hacked/ss1/serial/PositioningDecoder.go new file mode 100644 index 0000000..a847f9b --- /dev/null +++ b/modding/hacked/ss1/serial/PositioningDecoder.go @@ -0,0 +1,27 @@ +package serial + +import "io" + +// PositioningDecoder is a Decoder with positioning capabilities. +type PositioningDecoder struct { + Decoder + + seeker io.Seeker +} + +// NewPositioningDecoder creates a new PositioningDecoder from given reader. +func NewPositioningDecoder(source io.ReadSeeker) *PositioningDecoder { + return &PositioningDecoder{Decoder: Decoder{source: source, offset: 0}, seeker: source} +} + +// SetCurPos changes the encoding position to the specified absolute offset. +func (coder *PositioningDecoder) SetCurPos(offset uint32) { + if coder.firstError != nil { + return + } + _, coder.firstError = coder.seeker.Seek(int64(offset), io.SeekStart) + if coder.firstError != nil { + return + } + coder.offset = offset +} diff --git a/modding/hacked/ss1/serial/PositioningDecoder_test.go b/modding/hacked/ss1/serial/PositioningDecoder_test.go new file mode 100644 index 0000000..42779d3 --- /dev/null +++ b/modding/hacked/ss1/serial/PositioningDecoder_test.go @@ -0,0 +1,64 @@ +package serial_test + +import ( + "bytes" + "testing" + + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" +) + +func TestPositioningDecoderSetCurPosRepositionsReadOffset(t *testing.T) { + var source = bytes.NewReader([]byte{0x78, 0x12, 0x34}) + coder := serial.NewPositioningDecoder(source) + arrayValue := make([]byte, 3) + var intValue uint16 + + coder.Code(arrayValue) + coder.SetCurPos(1) + coder.Code(&intValue) + + assert.Equal(t, uint16(0x3412), intValue) +} + +func TestPositioningDecoderCurPosReturnsCurrentOffset(t *testing.T) { + var source = bytes.NewReader([]byte{0x78, 0x12, 0x34}) + coder := serial.NewPositioningDecoder(source) + + coder.Code(uint16(0)) + + assert.Equal(t, uint32(2), coder.CurPos()) +} + +func TestPositioningDecoderFirstErrorBySetCurPos(t *testing.T) { + var target errorBuffer + coder := serial.NewPositioningDecoder(&target) + + data := uint32(0) + coder.Code(&data) + target.errorOnNextCall = true + coder.SetCurPos(0) + + assert.EqualError(t, coder.FirstError(), "errorBuffer on call number 2") +} + +func TestPositioningDecoderSetCurPosDoesNothingOnPreviousError(t *testing.T) { + var target errorBuffer + coder := serial.NewPositioningDecoder(&target) + target.errorOnNextCall = true + coder.Code(uint32(0)) + target.errorOnNextCall = true + coder.SetCurPos(0) + + assert.Equal(t, target.callCounter, 1) + assert.EqualError(t, coder.FirstError(), "errorBuffer on call number 1") +} + +func TestCurPosIsNotChangedOnSetError(t *testing.T) { + var target errorBuffer + coder := serial.NewPositioningDecoder(&target) + target.errorOnNextCall = true + coder.SetCurPos(4) + assert.Equal(t, uint32(0), coder.CurPos()) +} diff --git a/modding/hacked/ss1/serial/PositioningEncoder.go b/modding/hacked/ss1/serial/PositioningEncoder.go new file mode 100644 index 0000000..41f1d50 --- /dev/null +++ b/modding/hacked/ss1/serial/PositioningEncoder.go @@ -0,0 +1,57 @@ +package serial + +import ( + "io" + + "github.com/inkyblackness/hacked/ss1" +) + +// PositioningEncoder is an Encoder with positioning capabilities. +type PositioningEncoder struct { + Encoder + + seeker io.Seeker +} + +// NewPositioningEncoder returns an Encoder that also implements the Positioner interface. +// The new Encoder starts with its zero position at the current position in the writer. +func NewPositioningEncoder(target io.WriteSeeker) *PositioningEncoder { + return &PositioningEncoder{Encoder: Encoder{target: target}, seeker: target} +} + +// SetCurPos changes the encoding position to the specified absolute offset. +func (coder *PositioningEncoder) SetCurPos(offset uint32) { + if coder.firstError != nil { + return + } + _, coder.firstError = coder.seeker.Seek(int64(offset)-int64(coder.offset), io.SeekCurrent) + if coder.firstError != nil { + return + } + coder.offset = offset +} + +const ( + errInvalidWhence ss1.StringError = "seek: invalid whence" + errSeekBeforeStart ss1.StringError = "seek: seeking before start" +) + +// Seek repositions the current encoding offset. +// This implementation does not support a whence value of io.SeekEnd. +func (coder *PositioningEncoder) Seek(offset int64, whence int) (int64, error) { + var newPosition int64 + switch whence { + default: + return int64(coder.offset), errInvalidWhence + case io.SeekStart: + newPosition = offset + case io.SeekCurrent: + newPosition = int64(coder.offset) + offset + } + if newPosition < 0 { + return int64(coder.offset), errSeekBeforeStart + } + + coder.SetCurPos(uint32(newPosition)) + return int64(coder.offset), coder.firstError +} diff --git a/modding/hacked/ss1/serial/PositioningEncoder_test.go b/modding/hacked/ss1/serial/PositioningEncoder_test.go new file mode 100644 index 0000000..228e592 --- /dev/null +++ b/modding/hacked/ss1/serial/PositioningEncoder_test.go @@ -0,0 +1,161 @@ +package serial_test + +import ( + "io" + "testing" + + "github.com/inkyblackness/hacked/ss1/serial" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type PositioningEncoderSuite struct { + suite.Suite + coder *serial.PositioningEncoder + store *serial.ByteStore +} + +func TestPositioningEncoderSuite(t *testing.T) { + suite.Run(t, new(PositioningEncoderSuite)) +} + +func (suite *PositioningEncoderSuite) SetupTest() { + suite.store = serial.NewByteStore() + suite.coder = serial.NewPositioningEncoder(suite.store) +} + +func (suite *PositioningEncoderSuite) TestImplementsPositioningCoderInterface() { + instance := interface{}(suite.coder) + _, ok := instance.(serial.PositioningCoder) + + assert.True(suite.T(), ok) +} + +func (suite *PositioningEncoderSuite) TestCurPosReturnsCurrentOffset() { + suite.coder.Code(uint32(0)) + assert.Equal(suite.T(), uint32(4), suite.coder.CurPos()) +} + +func (suite *PositioningEncoderSuite) TestSetCurPosRepositionsWritePointer() { + suite.coder.Code(uint32(0)) + suite.coder.SetCurPos(0) + suite.coder.Code(uint32(0x13243546)) + result := suite.store.Data() + + assert.Equal(suite.T(), []byte{0x46, 0x35, 0x24, 0x13}, result) +} + +func (suite *PositioningEncoderSuite) TestFirstErrorBySetCurPos() { + var target errorBuffer + suite.coder = serial.NewPositioningEncoder(&target) + + suite.coder.Code(uint32(0)) + target.errorOnNextCall = true + suite.coder.SetCurPos(0) + + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 2") +} + +func (suite *PositioningEncoderSuite) TestSetCurPosDoesNothingOnPreviousError() { + var target errorBuffer + suite.coder = serial.NewPositioningEncoder(&target) + target.errorOnNextCall = true + suite.coder.Code(uint32(0)) + target.errorOnNextCall = true + suite.coder.SetCurPos(0) + + assert.Equal(suite.T(), target.callCounter, 1) + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 1") +} + +func (suite *PositioningEncoderSuite) TestCurPosIsNotChangedOnSetError() { + var target errorBuffer + suite.coder = serial.NewPositioningEncoder(&target) + target.errorOnNextCall = true + suite.coder.SetCurPos(4) + assert.Equal(suite.T(), uint32(0), suite.coder.CurPos()) +} + +func (suite *PositioningEncoderSuite) TestCurPosIsRelativeToConstructionTime() { + _, _ = suite.store.Write([]byte{0x01, 0x02, 0x03}) + + assert.Equal(suite.T(), uint32(0), suite.coder.CurPos(), "CurPos should be zero at the start") + suite.coder.Code(uint16(0xAAAA)) + suite.coder.SetCurPos(1) + suite.coder.Code(byte(0xCC)) + + result := suite.store.Data() + assert.Equal(suite.T(), []byte{0x01, 0x02, 0x03, 0xAA, 0xCC}, result, "SetCurPos did not work relative to original start") +} + +func (suite *PositioningEncoderSuite) TestImplementsSeekerInterface() { + instance := interface{}(suite.coder) + _, ok := instance.(io.Seeker) + + assert.True(suite.T(), ok) +} + +func (suite *PositioningEncoderSuite) TestSeekReturnsErrorForWrongWhence() { + pos, err := suite.coder.Seek(1, 1234) + + assert.Equal(suite.T(), int64(0), pos, "zero position should be returned") + assert.EqualError(suite.T(), err, "seek: invalid whence") +} + +func (suite *PositioningEncoderSuite) TestSeekAbsolute() { + suite.coder.Code(uint32(0)) + pos, err := suite.coder.Seek(1, io.SeekStart) + assert.Nil(suite.T(), err, "no error expected") + assert.Equal(suite.T(), int64(1), pos, "invalid position returned") + + suite.coder.Code(uint16(0xAABB)) + result := suite.store.Data() + assert.Equal(suite.T(), []byte{0x00, 0xBB, 0xAA, 0x00}, result) + assert.Equal(suite.T(), uint32(3), suite.coder.CurPos(), "should update current position") +} + +func (suite *PositioningEncoderSuite) TestSeekRelative() { + suite.coder.Code(uint32(0)) + pos, err := suite.coder.Seek(-1, io.SeekCurrent) + assert.Equal(suite.T(), int64(3), pos, "should return new position") + assert.Nil(suite.T(), err, "should have no error") + + suite.coder.Code(uint16(0xAABB)) + + result := suite.store.Data() + assert.Equal(suite.T(), []byte{0x00, 0x00, 0x00, 0xBB, 0xAA}, result) + assert.Equal(suite.T(), uint32(5), suite.coder.CurPos(), "should update current position") +} + +func (suite *PositioningEncoderSuite) TestSeekDoesNothingOnPreviousError() { + var target errorBuffer + suite.coder = serial.NewPositioningEncoder(&target) + target.errorOnNextCall = true + suite.coder.Code(uint32(0)) + _, _ = suite.coder.Seek(0, io.SeekStart) + + assert.Equal(suite.T(), target.callCounter, 1) + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 1") +} + +func (suite *PositioningEncoderSuite) TestSeekRegistersReturnedError() { + var target errorBuffer + suite.coder = serial.NewPositioningEncoder(&target) + suite.coder.Code(uint32(0)) + target.errorOnNextCall = true + _, _ = suite.coder.Seek(1, io.SeekStart) + + pos, err := suite.coder.Seek(1, io.SeekStart) + + assert.Equal(suite.T(), int64(4), pos, "Should return old position value for seek") + assert.EqualError(suite.T(), err, "errorBuffer on call number 2") + assert.EqualError(suite.T(), suite.coder.FirstError(), "errorBuffer on call number 2", "should set first error") +} + +func (suite *PositioningEncoderSuite) TestSeekReturnsErrorForSeekBeforeStart() { + pos, err := suite.coder.Seek(-1, io.SeekStart) + + assert.Equal(suite.T(), int64(0), pos, "Should return old position value for seek") + assert.EqualError(suite.T(), err, "seek: seeking before start") +} diff --git a/modding/hacked/ss1/serial/SeekingReadCloser.go b/modding/hacked/ss1/serial/SeekingReadCloser.go new file mode 100644 index 0000000..113f8c1 --- /dev/null +++ b/modding/hacked/ss1/serial/SeekingReadCloser.go @@ -0,0 +1,10 @@ +package serial + +import "io" + +// SeekingReadCloser combines the interfaces of a Reader, a Seeker and a Closer. +type SeekingReadCloser interface { + io.Reader + io.Seeker + io.Closer +} diff --git a/modding/hacked/ss1/serial/SeekingWriteCloser.go b/modding/hacked/ss1/serial/SeekingWriteCloser.go new file mode 100644 index 0000000..f1485b4 --- /dev/null +++ b/modding/hacked/ss1/serial/SeekingWriteCloser.go @@ -0,0 +1,10 @@ +package serial + +import "io" + +// SeekingWriteCloser combines the interfaces of a Writer, a Seeker and a Closer. +type SeekingWriteCloser interface { + io.Writer + io.Seeker + io.Closer +} diff --git a/modding/hacked/ss1/serial/doc.go b/modding/hacked/ss1/serial/doc.go new file mode 100644 index 0000000..82480d9 --- /dev/null +++ b/modding/hacked/ss1/serial/doc.go @@ -0,0 +1,4 @@ +/* +Package serial provides serialization utilities for nested serialization. +*/ +package serial diff --git a/modding/hacked/ss1/serial/errorBuffer_test.go b/modding/hacked/ss1/serial/errorBuffer_test.go new file mode 100644 index 0000000..98d2bdf --- /dev/null +++ b/modding/hacked/ss1/serial/errorBuffer_test.go @@ -0,0 +1,56 @@ +package serial_test + +import ( + "fmt" + "io" + + "github.com/inkyblackness/hacked/ss1" +) + +type errorBuffer struct { + position int64 + callCounter int + errorOnNextCall bool + errorByteCount int +} + +func (buf *errorBuffer) check() (err error) { + buf.callCounter++ + if buf.errorOnNextCall { + buf.errorOnNextCall = false + err = ss1.StringError(fmt.Sprintf("errorBuffer on call number %v", buf.callCounter)) + } + return +} + +func (buf *errorBuffer) Seek(offset int64, whence int) (int64, error) { + err := buf.check() + if err != nil { + return 0, err + } + switch whence { + case io.SeekStart: + buf.position = offset + case io.SeekCurrent: + buf.position += offset + case io.SeekEnd: + panic("SeekEnd not supported") + } + return buf.position, nil +} + +func (buf *errorBuffer) Read(p []byte) (n int, err error) { + err = buf.check() + if err != nil { + return buf.errorByteCount, err + } + return len(p), nil +} + +func (buf *errorBuffer) Write(p []byte) (n int, err error) { + err = buf.check() + if err != nil { + return buf.errorByteCount, err + } + return len(p), nil +} diff --git a/modding/hacked/ss1/serial/rle/Compress.go b/modding/hacked/ss1/serial/rle/Compress.go new file mode 100644 index 0000000..c5d690f --- /dev/null +++ b/modding/hacked/ss1/serial/rle/Compress.go @@ -0,0 +1,177 @@ +package rle + +import "io" + +// Compress compresses the given byte array into the given writer. +// The optional reference array is used as a delta basis. If provided, bytes will be skipped +// where the data equals the reference. +func Compress(writer io.Writer, data []byte, reference []byte) error { + end := len(data) + refLen := len(reference) + ref := func(index int) byte { + if index < refLen { + return reference[index] + } + return 0x00 + } + + countIdenticalBytes := func(from int) int { + index := from + for (index < end) && (data[index] == ref(index)) { + index++ + } + return index - from + } + countConstantBytes := func(from int, value byte) int { + index := from + for (index < end) && (data[index] == value) { + index++ + } + return index - from + } + + { + trailingSkip := 0 + + for temp := end - 1; (temp >= 0) && (data[temp] == ref(temp)); temp-- { + trailingSkip++ + } + end -= trailingSkip % 0x7FFF + } + start := 0 + for start < end { + identicalByteCount := countIdenticalBytes(start) + constByteCount := countConstantBytes(start, data[start]) + + switch { + case identicalByteCount > 3: + err := skipBytes(writer, identicalByteCount) + if err != nil { + return err + } + start += identicalByteCount + case constByteCount > 3: + err := writeConstant(writer, constByteCount, data[start]) + if err != nil { + return err + } + start += constByteCount + default: + diffByteCount := 0 + abort := false + + for (start+diffByteCount) < end && !abort { + nextIdenticalByteCount := countIdenticalBytes(start + diffByteCount) + nextConstByteCount := countConstantBytes(start+diffByteCount, data[start+diffByteCount]) + + if nextIdenticalByteCount < 4 && nextConstByteCount < 4 { + if nextIdenticalByteCount > nextConstByteCount { + diffByteCount += nextIdenticalByteCount + } else { + diffByteCount += nextConstByteCount + } + } else { + abort = true + } + } + err := writeRaw(writer, data[start:start+diffByteCount]) + if err != nil { + return err + } + start += diffByteCount + } + } + return writeExtended(writer, 0x0000) +} + +func writeExtended(writer io.Writer, control uint16, extra ...byte) error { + _, err := writer.Write([]byte{0x80, byte(control & 0xFF), byte((control >> 8) & 0xFF)}) + if err != nil { + return err + } + _, err = writer.Write(extra) + return err +} + +func skipBytes(writer io.Writer, size int) error { + remain := size + + for remain > 0 { + if remain < 0x80 { + _, err := writer.Write([]byte{byte(0x80 + remain)}) + if err != nil { + return err + } + remain = 0 + } else { + lenControl := 0x7FFF + if remain < lenControl { + lenControl = remain + } + err := writeExtended(writer, uint16(lenControl)) + if err != nil { + return err + } + remain -= lenControl + } + } + return nil +} + +func writeConstant(writer io.Writer, size int, value byte) error { + start := 0 + + for start < size { + remain := size - start + if remain < 0x100 { + _, err := writer.Write([]byte{0x00, byte(remain), value}) + if err != nil { + return err + } + start = size + } else { + lenControl := 0x3FFF + if remain < lenControl { + lenControl = remain + } + err := writeExtended(writer, 0xC000+uint16(lenControl), value) + if err != nil { + return err + } + start += lenControl + } + } + return nil +} + +func writeRaw(writer io.Writer, data []byte) error { + end := len(data) + start := 0 + + for start < end { + remain := end - start + if remain < 0x80 { + _, err := writer.Write([]byte{byte(remain)}) + if err != nil { + return err + } + _, err = writer.Write(data[start:]) + if err != nil { + return err + } + start = end + } else { + lenControl := 0x3FFF + + if remain < lenControl { + lenControl = remain + } + err := writeExtended(writer, 0x8000+uint16(lenControl), data[start:start+lenControl]...) + if err != nil { + return err + } + start += lenControl + } + } + return nil +} diff --git a/modding/hacked/ss1/serial/rle/Compress_test.go b/modding/hacked/ss1/serial/rle/Compress_test.go new file mode 100644 index 0000000..45b43cd --- /dev/null +++ b/modding/hacked/ss1/serial/rle/Compress_test.go @@ -0,0 +1,79 @@ +package rle_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/serial/rle" +) + +func TestCompressEmptyArrayResultsInTerminator(t *testing.T) { + writer := bytes.NewBuffer(nil) + err := rle.Compress(writer, nil, nil) + assert.Nil(t, err, "no error expected") + assert.Equal(t, []byte{0x80, 0x00, 0x00}, writer.Bytes()) +} + +func TestCompressOfZeroBytesLong(t *testing.T) { + writer := bytes.NewBuffer(nil) + input := make([]byte, 1000) + input[len(input)-1] = 1 + err := rle.Compress(writer, input, nil) + assert.Nil(t, err, "no error expected") + assert.Equal(t, []byte{0x80, 0xE7, 0x03, 0x01, 0x01, 0x80, 0x0, 0x0}, writer.Bytes()) +} + +func TestCompressOfEqualBytesLong(t *testing.T) { + writer := bytes.NewBuffer(nil) + input := make([]byte, 1000) + for i := 0; i < len(input); i++ { + input[i] = 0xAA + } + input[len(input)-1] = 1 + err := rle.Compress(writer, input, nil) + assert.Nil(t, err, "no error expected") + assert.Equal(t, []byte{0x80, 0xE7, 0xC3, 0xAA, 0x01, 0x01, 0x80, 0x0, 0x0}, writer.Bytes()) +} + +func TestCompressOfEqualBytesShort(t *testing.T) { + writer := bytes.NewBuffer(nil) + input := make([]byte, 40) + for i := 0; i < len(input); i++ { + input[i] = 0xAA + } + input[len(input)-1] = 1 + err := rle.Compress(writer, input, nil) + assert.Nil(t, err, "no error expected") + assert.Equal(t, []byte{0x00, 0x27, 0xAA, 0x01, 0x01, 0x80, 0x0, 0x0}, writer.Bytes()) +} + +func TestCompressOfRandomBytesShort(t *testing.T) { + writer := bytes.NewBuffer(nil) + input := make([]byte, 10) + for i := 0; i < len(input); i++ { + input[i] = byte(i) + } + err := rle.Compress(writer, input, nil) + assert.Nil(t, err, "no error expected") + assert.Equal(t, []byte{0x0A, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x80, 0x00, 0x00}, writer.Bytes()) +} + +func TestCompressWithIdenticalReference(t *testing.T) { + writer := bytes.NewBuffer(nil) + input := []byte{0xAA, 0xBB, 0xCC, 0xDD} + reference := input + err := rle.Compress(writer, input, reference) + assert.Nil(t, err, "no error expected") + assert.Equal(t, []byte{0x80, 0x00, 0x00}, writer.Bytes()) +} + +func TestCompressWithPartlyIdenticalReference(t *testing.T) { + writer := bytes.NewBuffer(nil) + input := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF} + reference := []byte{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x0A} + err := rle.Compress(writer, input, reference) + assert.Nil(t, err, "no error expected") + assert.Equal(t, []byte{0x85, 0x01, 0xFF, 0x80, 0x00, 0x00}, writer.Bytes()) +} diff --git a/modding/hacked/ss1/serial/rle/Decompress.go b/modding/hacked/ss1/serial/rle/Decompress.go new file mode 100644 index 0000000..62937c0 --- /dev/null +++ b/modding/hacked/ss1/serial/rle/Decompress.go @@ -0,0 +1,67 @@ +package rle + +import ( + "io" + + "github.com/inkyblackness/hacked/ss1" +) + +const ( + errUndefinedCase ss1.StringError = "undefined case 80 nn C0" +) + +// Decompress decompresses from the given reader and writes into the provided output buffer. +// The output buffer must be pre-allocated. +// If it contains non-zero data, this data may be preserved if the compressed data specifies to. +func Decompress(reader io.Reader, output []byte) (err error) { + outIndex := 0 + done := false + nextByte := func() byte { + zz := []byte{0x00} + _, err = reader.Read(zz) + return zz[0] + } + + for !done && (err == nil) { + first := nextByte() + + switch { + case first == 0x00: + nn := nextByte() + zz := nextByte() + + outIndex += writeBytesOfValue(output[outIndex:outIndex+int(nn)], func() byte { return zz }) + case first < 0x80: + outIndex += writeBytesOfValue(output[outIndex:outIndex+int(first)], nextByte) + case first == 0x80: + control := uint16(nextByte()) + control += uint16(nextByte()) << 8 + switch { + case control == 0x0000: + done = true + case control < 0x8000: + outIndex += int(control) + case control < 0xC000: + outIndex += writeBytesOfValue(output[outIndex:outIndex+int(control&0x3FFF)], nextByte) + case (control & 0xFF00) == 0xC000: + err = errUndefinedCase + default: + zz := nextByte() + + outIndex += writeBytesOfValue(output[outIndex:outIndex+int(control&0x3FFF)], func() byte { return zz }) + } + default: + outIndex += int(first & 0x7F) + } + } + + return +} + +func writeBytesOfValue(buffer []byte, producer func() byte) int { + count := len(buffer) + for i := 0; i < count; i++ { + buffer[i] = producer() + } + return count +} diff --git a/modding/hacked/ss1/serial/rle/Decompress_test.go b/modding/hacked/ss1/serial/rle/Decompress_test.go new file mode 100644 index 0000000..ece8609 --- /dev/null +++ b/modding/hacked/ss1/serial/rle/Decompress_test.go @@ -0,0 +1,146 @@ +package rle_test + +import ( + "bytes" + "testing" + + "github.com/inkyblackness/hacked/ss1/serial/rle" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecompressEmptyArrayReturnsError(t *testing.T) { + err := rle.Decompress(bytes.NewReader(nil), make([]byte, 100)) + + assert.NotNil(t, err) +} + +func TestDecompress800000IsEndOfStream(t *testing.T) { + var result []byte + err := rle.Decompress(bytes.NewReader([]byte{0x80, 0x00, 0x00}), result) + + assert.Nil(t, err) +} + +func TestDecompress800000AppendsRemainingZeroes(t *testing.T) { + result := make([]byte, 10) + err := rle.Decompress(bytes.NewReader([]byte{0x80, 0x00, 0x00}), result) + + require.Nil(t, err) + assert.Equal(t, make([]byte, 10), result) +} + +func TestDecompress800000IsConsumedAtEnd(t *testing.T) { + var result []byte + reader := bytes.NewReader([]byte{0x80, 0x00, 0x00}) + err := rle.Decompress(reader, result) + + require.Nil(t, err) + pos, _ := reader.Seek(0, 1) + assert.Equal(t, int64(3), pos) +} + +func TestDecompress00WritesNNBytesOfColorZZ(t *testing.T) { + result := make([]byte, 5) + err := rle.Decompress(bytes.NewReader([]byte{0x00, 0x05, 0xCC, 0x80, 0x00, 0x00}), result) + + require.Nil(t, err) + assert.Equal(t, []byte{0xCC, 0xCC, 0xCC, 0xCC, 0xCC}, result) +} + +func TestDecompress00ReturnsErrorIfZZIsMissing(t *testing.T) { + err := rle.Decompress(bytes.NewReader([]byte{0x00, 0x05}), make([]byte, 5)) + + assert.NotNil(t, err) +} + +func TestDecompress00ReturnsErrorIfNNIsMissing(t *testing.T) { + err := rle.Decompress(bytes.NewReader([]byte{0x00}), make([]byte, 5)) + + assert.NotNil(t, err) +} + +func TestDecompressNNLess80WritesNNFollowingBytes(t *testing.T) { + result := make([]byte, 2) + err := rle.Decompress(bytes.NewReader([]byte{0x02, 0xAA, 0xBB, 0x80, 0x00, 0x00}), result) + + require.Nil(t, err) + assert.Equal(t, []byte{0xAA, 0xBB}, result) +} + +func TestDecompressNNLess80ReturnsErrorIfEndOfFile(t *testing.T) { + err := rle.Decompress(bytes.NewReader([]byte{0x02, 0xAA}), make([]byte, 2)) + + assert.NotNil(t, err) +} + +func TestDecompress80NNNNSkipsBytes(t *testing.T) { + expected := make([]byte, 0x123) + result := make([]byte, 0x123) + for i := 0; i < len(result); i++ { + result[i] = byte(i) + } + copy(expected, result) + err := rle.Decompress(bytes.NewReader([]byte{0x80, 0x23, 0x01, 0x80, 0x00, 0x00}), result) + + require.Nil(t, err) + assert.Equal(t, expected, result) +} + +func TestDecompress80CopiesNNBytes(t *testing.T) { + input := bytes.NewBuffer(nil) + input.Write([]byte{0x80, 0x04, 0x80}) + input.Write([]byte{0x01, 0x02, 0x03, 0x04}) + input.Write([]byte{0x80, 0x00, 0x00}) + result := make([]byte, 4) + err := rle.Decompress(bytes.NewReader(input.Bytes()), result) + + require.Nil(t, err) + assert.Equal(t, []byte{0x01, 0x02, 0x03, 0x04}, result) +} + +func TestDecompress80CopiesNNBytesExtended(t *testing.T) { + input := bytes.NewBuffer(nil) + expected := make([]byte, 0x3FFF) + input.Write([]byte{0x80, 0xFF, 0xBF}) + input.Write(expected) + input.Write([]byte{0x80, 0x00, 0x00}) + result := make([]byte, len(expected)) + err := rle.Decompress(bytes.NewReader(input.Bytes()), result) + + require.Nil(t, err) + assert.Equal(t, expected, result) +} + +func TestDecompress80ReturnsErrorForUndefinedCase(t *testing.T) { + input := bytes.NewBuffer(nil) + input.Write([]byte{0x80, 0x00, 0xC0}) + input.Write([]byte{0x80, 0x00, 0x00}) + err := rle.Decompress(bytes.NewReader(input.Bytes()), make([]byte, 1)) + + assert.NotNil(t, err) +} + +func TestDecompress80WritesNNBytesOfValue(t *testing.T) { + input := bytes.NewBuffer(nil) + expected := make([]byte, 0x3FFF) + for index := range expected { + expected[index] = 0xCD + } + input.Write([]byte{0x80, 0xFF, 0xFF, 0xCD}) + input.Write([]byte{0x80, 0x00, 0x00}) + result := make([]byte, len(expected)) + err := rle.Decompress(bytes.NewReader(input.Bytes()), result) + + require.Nil(t, err) + assert.Equal(t, expected, result) +} + +func TestDecompressNNMore80WritesZeroes(t *testing.T) { + result := make([]byte, 3) + err := rle.Decompress(bytes.NewReader([]byte{0x83, 0x80, 0x00, 0x00}), result) + + require.Nil(t, err) + assert.Equal(t, []byte{0, 0, 0}, result) +} diff --git a/modding/hacked/ss1/serial/rle/benchmark_test.go b/modding/hacked/ss1/serial/rle/benchmark_test.go new file mode 100644 index 0000000..60f226e --- /dev/null +++ b/modding/hacked/ss1/serial/rle/benchmark_test.go @@ -0,0 +1,83 @@ +package rle_test + +import ( + "bytes" + "math/rand" + "testing" + "time" + + "github.com/inkyblackness/hacked/ss1/serial/rle" +) + +func benchmarkRecompression(b *testing.B, size int, chanceLimit byte, nameSuffix string, seed int64) { + b.Helper() + input := make([]byte, size) + reference := make([]byte, size) + var chance [1]byte + + rand.Seed(seed) // nolint:gas + + b.ResetTimer() + for run := 0; run < b.N; run++ { + b.StopTimer() + rand.Read(input) // nolint:gas + copy(reference, input) + for i := 0; i < size; i++ { + rand.Read(chance[:]) // nolint:gas + if chance[0] < chanceLimit { + input[i] = chance[0] + } + } + buf := bytes.NewBuffer(nil) + b.StartTimer() + + err := rle.Compress(buf, input, reference) + if err != nil { + b.Errorf("Failed compression for %s in run %d of seed %v", nameSuffix, run, seed) + } + + err = rle.Decompress(bytes.NewReader(buf.Bytes()), reference) + if err != nil { + b.Errorf("Failed decompression for %s in run %d of seed %v", nameSuffix, run, seed) + } + if !bytes.Equal(input, reference) { + b.Errorf("Data mismatch in %s in run %d of seed %v", nameSuffix, run, seed) + } + } +} + +func BenchmarkRecompression64KB_10(b *testing.B) { + benchmarkRecompression(b, 1024*64, 10, "64KB,10", time.Now().UnixNano()) +} + +func BenchmarkRecompression64KB_128(b *testing.B) { + benchmarkRecompression(b, 1024*64, 128, "64KB,128", time.Now().UnixNano()) +} + +func BenchmarkRecompression1KB_0(b *testing.B) { + benchmarkRecompression(b, 1024, 0, "1KB,0", time.Now().UnixNano()) +} + +func BenchmarkRecompression1KB_10(b *testing.B) { + benchmarkRecompression(b, 1024, 10, "1KB,10", time.Now().UnixNano()) +} + +func BenchmarkRecompression1KB_128(b *testing.B) { + benchmarkRecompression(b, 1024, 128, "1KB,128", time.Now().UnixNano()) +} + +func BenchmarkRecompression1KB_255(b *testing.B) { + benchmarkRecompression(b, 1024, 255, "1KB,255", time.Now().UnixNano()) +} + +func BenchmarkRecompression128B_10(b *testing.B) { + benchmarkRecompression(b, 128, 10, "128B,10", time.Now().UnixNano()) +} + +func BenchmarkRecompression128B_128(b *testing.B) { + benchmarkRecompression(b, 128, 128, "128B,128", time.Now().UnixNano()) +} + +func BenchmarkRecompression128B_255(b *testing.B) { + benchmarkRecompression(b, 128, 255, "128B,255", time.Now().UnixNano()) +} diff --git a/modding/hacked/ss1/world/BlockPatch.go b/modding/hacked/ss1/world/BlockPatch.go new file mode 100644 index 0000000..317aea6 --- /dev/null +++ b/modding/hacked/ss1/world/BlockPatch.go @@ -0,0 +1,19 @@ +package world + +import "github.com/inkyblackness/hacked/ss1/resource" + +// BlockPatch describes a raw delta-change of a resource block. +// The change can be applied, and reverted. +type BlockPatch struct { + // ID identifier of the resource + ID resource.ID + // BlockIndex identifies the block for which the patch applies. + BlockIndex int + // BlockLength describes the expected length of the block. + BlockLength int + + // ForwardData contains the delta information to patch to a new version. + ForwardData []byte + // ReverseData contains the delta information to change a new version back to the original. + ReverseData []byte +} diff --git a/modding/hacked/ss1/world/Constants.go b/modding/hacked/ss1/world/Constants.go new file mode 100644 index 0000000..34617b3 --- /dev/null +++ b/modding/hacked/ss1/world/Constants.go @@ -0,0 +1,31 @@ +package world + +import "github.com/inkyblackness/hacked/ss1" + +const ( + // StartingLevel identifies the level a new game is started in by default. + StartingLevel = 1 + // StartingTileX identifies the default X position of the protagonist. + StartingTileX = 30 + // StartingTileY identifies the default Y position of the protagonist. + StartingTileY = 22 + + // MaxWorldTextures is the limit of how many textures the engine supports. + // Note that this value is equal to that of the resource limits in package ids. It is actually based on them. + MaxWorldTextures = 293 +) + +const ( + // TexturePropertiesFilename specifies the lowercase name of the file containing texture properties. + TexturePropertiesFilename = "textprop.dat" + + // ObjectPropertiesFilename specifies the lowercase name of the file containing object properties. + ObjectPropertiesFilename = "objprop.dat" +) + +const ( + errNoResourcesFound ss1.StringError = "resource unknown" + errBlockLengthMismatch ss1.StringError = "block length mismatch" + errIndexOutOfBounds ss1.StringError = "index is out of bounds" + errEntryIsNil ss1.StringError = "entry is nil" +) diff --git a/modding/hacked/ss1/world/Defaults.go b/modding/hacked/ss1/world/Defaults.go new file mode 100644 index 0000000..04d5d6d --- /dev/null +++ b/modding/hacked/ss1/world/Defaults.go @@ -0,0 +1,8 @@ +package world + +// IsConsideredCyberspaceByDefault returns true for those level identifier +// that are hardcoded to be cyberspace levels. +// For the vanilla engine, this is true for level 10 and any above 13. +func IsConsideredCyberspaceByDefault(levelID int) bool { + return (levelID == 10) || (levelID > 13) +} diff --git a/modding/hacked/ss1/world/Defaults_test.go b/modding/hacked/ss1/world/Defaults_test.go new file mode 100644 index 0000000..f9dc1c0 --- /dev/null +++ b/modding/hacked/ss1/world/Defaults_test.go @@ -0,0 +1,23 @@ +package world_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/world" + + "github.com/stretchr/testify/assert" +) + +func TestDefaultCyberspaceLevels(t *testing.T) { + tt := []bool{ + false, false, false, false, + false, false, false, false, + false, false, true, false, + false, false, true, true, + } + + for levelID, expected := range tt { + result := world.IsConsideredCyberspaceByDefault(levelID) + assert.Equal(t, expected, result, "Cyberspace mismatch") + } +} diff --git a/modding/hacked/ss1/world/FileLoader.go b/modding/hacked/ss1/world/FileLoader.go new file mode 100644 index 0000000..458e8b3 --- /dev/null +++ b/modding/hacked/ss1/world/FileLoader.go @@ -0,0 +1,208 @@ +package world + +import ( + "archive/zip" + "bytes" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/resource/lgres" + "github.com/inkyblackness/hacked/ss1/serial" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +var fileAllowlist = ids.FilenameList{ + ids.Archive, + ids.CybStrng, + ids.CitALog, + ids.CitBark, + ids.CitMat, + ids.DigiFX, + ids.GamePal, + ids.GameScr, + ids.MfdArt, + ids.Obj3D, + ids.ObjArt, + ids.ObjArt2, + ids.ObjArt3, + ids.SideArt, + ids.Splash, + ids.SvgaDeth, + ids.SvgaEnd, + ids.SvgaIntr, + ids.Texture, + ids.VidMail, +} + +type fileLoader struct { + resultMutex sync.Mutex + + allowZips bool + + result FileLoadResult +} + +// FileLoadResult contains all the information of a LoadFiles attempt. +type FileLoadResult struct { + FailedFiles int + Savegames map[FileLocation]resource.Viewer + Resources map[FileLocation]resource.Viewer + + ObjectProperties object.PropertiesTable + TextureProperties texture.PropertiesList +} + +// LoadFiles attempts to load compatible files from the given set of filenames. +func LoadFiles(allowZips bool, names []string) FileLoadResult { + loader := fileLoader{ + allowZips: allowZips, + result: FileLoadResult{ + Resources: make(map[FileLocation]resource.Viewer), + Savegames: make(map[FileLocation]resource.Viewer), + }, + } + loader.loadAll(names) + return loader.result +} + +func (loader *fileLoader) loadAll(names []string) { + loader.loadList(names, len(names) == 1) +} + +func (loader *fileLoader) loadList(names []string, isOnlyRequestedFile bool) { + var wg sync.WaitGroup + + for _, name := range names { + wg.Add(1) + go func(name string) { + defer wg.Done() + loader.load(name, isOnlyRequestedFile) + }(name) + } + wg.Wait() +} + +func (loader *fileLoader) load(name string, isOnlyRequestedFile bool) { + fileInfo, err := os.Stat(name) + if err != nil { + loader.markFailedFile() + return + } + file, err := os.Open(name) + if err != nil { + loader.markFailedFile() + return + } + defer func() { + _ = file.Close() // nolint: gas + }() + + if fileInfo.IsDir() { + if isOnlyRequestedFile { + subNames, _ := file.Readdirnames(0) + joinedSubNames := make([]string, len(subNames)) + for index, subName := range subNames { + joinedSubNames[index] = filepath.Join(name, subName) + } + loader.loadList(joinedSubNames, false) + } + } else { + tryDirect := true + if loader.allowZips && isOnlyRequestedFile { + tryDirect = false + zipReader, err := zip.NewReader(file, fileInfo.Size()) + if err != nil { + _, _ = file.Seek(0, io.SeekStart) + tryDirect = true + } else { + loader.loadFileArchive(zipReader) + } + } + + if tryDirect { + loader.loadFile(name, isOnlyRequestedFile, file) + } + } +} + +func (loader *fileLoader) loadFileArchive(zipReader *zip.Reader) { + var wg sync.WaitGroup + + for _, file := range zipReader.File { + wg.Add(1) + go func(entry *zip.File) { + defer wg.Done() + entryFile, err := entry.Open() + if err != nil { + return + } + defer func() { + _ = entryFile.Close() + }() + + loader.loadFile(entry.Name, false, entryFile) + }(file) + } + wg.Wait() +} + +func (loader *fileLoader) loadFile(name string, isOnlyStagedFile bool, file io.Reader) { + fileData, err := ioutil.ReadAll(file) + if err != nil { + loader.markFailedFile() + } + + reader, err := lgres.ReaderFrom(bytes.NewReader(fileData)) + filename := filepath.Base(name) + if (err == nil) && (isOnlyStagedFile || fileAllowlist.Matches(filename)) { + location := FileLocation{DirPath: filepath.Dir(name), Name: filename} + loader.modify(func() { + if stateView, stateErr := reader.View(ids.GameState); (stateErr == nil) && archive.IsSavegame(stateView) { + loader.result.Savegames[location] = reader + } else { + loader.result.Resources[location] = reader + } + }) + } + if strings.ToLower(filename) == ObjectPropertiesFilename { + decoder := serial.NewDecoder(bytes.NewReader(fileData)) + properties := object.StandardPropertiesTable() + properties.Code(decoder) + err = decoder.FirstError() + if err == nil { + loader.modify(func() { loader.result.ObjectProperties = properties }) + } + } + if strings.ToLower(filename) == TexturePropertiesFilename && (len(fileData) > 4) { + decoder := serial.NewDecoder(bytes.NewReader(fileData)) + entryCount := (len(fileData) - 4) / texture.PropertiesSize + properties := make(texture.PropertiesList, entryCount) + properties.Code(decoder) + err = decoder.FirstError() + if err == nil { + loader.modify(func() { loader.result.TextureProperties = properties }) + } + } + + if err != nil { + loader.markFailedFile() + } +} + +func (loader *fileLoader) markFailedFile() { + loader.modify(func() { loader.result.FailedFiles++ }) +} + +func (loader *fileLoader) modify(modifier func()) { + loader.resultMutex.Lock() + defer loader.resultMutex.Unlock() + modifier() +} diff --git a/modding/hacked/ss1/world/FileLocation.go b/modding/hacked/ss1/world/FileLocation.go new file mode 100644 index 0000000..8090bd0 --- /dev/null +++ b/modding/hacked/ss1/world/FileLocation.go @@ -0,0 +1,48 @@ +package world + +import ( + "path/filepath" + "strings" +) + +// FileLocation describes the path and name of a file. +type FileLocation struct { + DirPath string + Name string +} + +// FileLocationFrom returns an instance for given filename. +func FileLocationFrom(filename string) FileLocation { + return FileLocation{ + DirPath: filepath.Dir(filename), + Name: filepath.Base(filename), + } +} + +// AbsolutePathFrom returns the complete path, based on given base, of the location. +func (loc FileLocation) AbsolutePathFrom(base string) string { + rel, err := filepath.Rel(base, loc.DirPath) + if err != nil { + if filepath.IsAbs(loc.DirPath) { + return filepath.Join(loc.DirPath, loc.Name) + } + return filepath.Join(base, loc.DirPath, loc.Name) + } + return filepath.Join(base, rel, loc.Name) +} + +// NestedRelativeTo returns a string that describes the relative nested location to given base - if possible. +func (loc FileLocation) NestedRelativeTo(base string) string { + absoluteDir := loc.DirPath + if !filepath.IsAbs(absoluteDir) { + absoluteDir = filepath.Clean(filepath.Join(base, loc.DirPath)) + } + if !strings.HasPrefix(absoluteDir+string(filepath.Separator), base+string(filepath.Separator)) { + return filepath.Join(absoluteDir, loc.Name) + } + rel, err := filepath.Rel(base, absoluteDir) + if err != nil { + return filepath.Join(base, loc.Name) + } + return filepath.Join(".", rel, loc.Name) +} diff --git a/modding/hacked/ss1/world/FileLocation_test.go b/modding/hacked/ss1/world/FileLocation_test.go new file mode 100644 index 0000000..e4a90aa --- /dev/null +++ b/modding/hacked/ss1/world/FileLocation_test.go @@ -0,0 +1,125 @@ +package world_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/inkyblackness/hacked/ss1/world" +) + +func TestFileLocationAbsolutePathFrom(t *testing.T) { + temp := os.TempDir() + base := filepath.Join(temp, "base") + + tt := []struct { + name string + loc world.FileLocation + base string + expected string + }{ + { + name: "file loaded in same base", + loc: world.FileLocation{DirPath: base, Name: "test.res"}, + base: base, + expected: filepath.Join(base, "test.res"), + }, + { + name: "new file", + loc: world.FileLocation{DirPath: ".", Name: "test.res"}, + base: base, + expected: filepath.Join(base, "test.res"), + }, + { + name: "new file without dir path", + loc: world.FileLocation{DirPath: "", Name: "test.res"}, + base: base, + expected: filepath.Join(base, "test.res"), + }, + { + name: "file found in different sibling path", + loc: world.FileLocation{DirPath: filepath.Join(temp, "other"), Name: "test.res"}, + base: base, + expected: filepath.Join(temp, "other", "test.res"), + }, + { + name: "file found in sub path", + loc: world.FileLocation{DirPath: filepath.Join(base, "nested"), Name: "test.res"}, + base: base, + expected: filepath.Join(base, "nested", "test.res"), + }, + { + name: "nested relative file dir path", + loc: world.FileLocation{DirPath: "nested", Name: "test.res"}, + base: base, + expected: filepath.Join(base, "nested", "test.res"), + }, + } + for _, tc := range tt { + func(name string, loc world.FileLocation, base, expected string) { + t.Run(name, func(t *testing.T) { + abs := loc.AbsolutePathFrom(base) + assert.Equal(t, expected, abs) + }) + }(tc.name, tc.loc, tc.base, tc.expected) + } +} + +func TestFileLocationNestedRelativeTo(t *testing.T) { + temp := os.TempDir() + base := filepath.Join(temp, "base") + + tt := []struct { + name string + loc world.FileLocation + base string + expected string + }{ + { + name: "file loaded in same base", + loc: world.FileLocation{DirPath: base, Name: "test.res"}, + base: base, + expected: filepath.Join(".", "test.res"), + }, + { + name: "new file", + loc: world.FileLocation{DirPath: ".", Name: "test.res"}, + base: base, + expected: filepath.Join(".", "test.res"), + }, + { + name: "new file without dir path", + loc: world.FileLocation{DirPath: "", Name: "test.res"}, + base: base, + expected: filepath.Join(".", "test.res"), + }, + { + name: "file found in different path", + loc: world.FileLocation{DirPath: filepath.Join(temp, "other"), Name: "test.res"}, + base: base, + expected: filepath.Join(temp, "other", "test.res"), + }, + { + name: "file found in sub path", + loc: world.FileLocation{DirPath: filepath.Join(base, "nested"), Name: "test.res"}, + base: base, + expected: filepath.Join(".", "nested", "test.res"), + }, + { + name: "file found in similar named different path", + loc: world.FileLocation{DirPath: filepath.Join(temp, "baseOther"), Name: "test.res"}, + base: base, + expected: filepath.Join(temp, "baseOther", "test.res"), + }, + } + for _, tc := range tt { + func(name string, loc world.FileLocation, base, expected string) { + t.Run(name, func(t *testing.T) { + rel := loc.NestedRelativeTo(base) + assert.Equal(t, expected, rel) + }) + }(tc.name, tc.loc, tc.base, tc.expected) + } +} diff --git a/modding/hacked/ss1/world/Manifest.go b/modding/hacked/ss1/world/Manifest.go new file mode 100644 index 0000000..f888152 --- /dev/null +++ b/modding/hacked/ss1/world/Manifest.go @@ -0,0 +1,170 @@ +package world + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// Manifest contains all the data and information of concrete things in a world. +type Manifest struct { + resourceChangeNotifier resource.ChangeNotifier + entries []*ManifestEntry +} + +// NewManifest returns a new instance that notifies changes to the provided callback. +func NewManifest(modified resource.ModificationCallback) *Manifest { + var manifest Manifest + + manifest.resourceChangeNotifier.Callback = modified + manifest.resourceChangeNotifier.Localizer = &manifest + + return &manifest +} + +// EntryCount returns the number of entries currently in the manifest. +func (manifest Manifest) EntryCount() int { + return len(manifest.entries) +} + +// Entry returns the entry at given index. +func (manifest Manifest) Entry(at int) (*ManifestEntry, error) { + if (at < 0) || (at >= len(manifest.entries)) { + return nil, errIndexOutOfBounds + } + return manifest.entries[at], nil +} + +// Reset clears the manifest by removing all entries and notifying of all the removed resource identifier. +func (manifest *Manifest) Reset() { + oldEntries := manifest.entries + manifest.changeAndNotify(func() { + manifest.entries = nil + }, manifest.listIDs(oldEntries...)) +} + +// InsertEntry puts the provided entries in sequence at the specified index. +// Any entry at the identified index, and all those after that, are moved behind the given ones. +func (manifest *Manifest) InsertEntry(at int, entries ...*ManifestEntry) error { + oldLen := len(manifest.entries) + if (at < 0) || (at > oldLen) { + return errIndexOutOfBounds + } + for _, entry := range entries { + if entry == nil { + return errEntryIsNil + } + } + manifest.changeAndNotify(func() { + addLen := len(entries) + newEntries := make([]*ManifestEntry, oldLen+addLen) + copy(newEntries[:at], manifest.entries[:at]) + copy(newEntries[at:], entries) + copy(newEntries[at+addLen:oldLen+addLen], manifest.entries[at:oldLen]) + manifest.entries = newEntries + }, manifest.listIDs(entries...)) + return nil +} + +// RemoveEntry removes the entry at given index. +func (manifest *Manifest) RemoveEntry(at int) error { + oldLen := len(manifest.entries) + if (at < 0) || (at >= oldLen) { + return errIndexOutOfBounds + } + manifest.changeAndNotify(func() { + copy(manifest.entries[at:oldLen-1], manifest.entries[at+1:oldLen]) + manifest.entries = manifest.entries[:oldLen-1] + }, manifest.listIDs(manifest.entries[at])) + return nil +} + +// ReplaceEntry removes the current entry at the identified index and puts the provided one instead. +func (manifest *Manifest) ReplaceEntry(at int, entry *ManifestEntry) error { + if (at < 0) || (at >= len(manifest.entries)) { + return errIndexOutOfBounds + } + if entry == nil { + return errEntryIsNil + } + manifest.changeAndNotify(func() { + manifest.entries[at] = entry + }, manifest.listIDs(manifest.entries[at]), manifest.listIDs(entry)) + + return nil +} + +// MoveEntry removes an entry and reinserts it at another index. +// Both indices are resolved before the move. +func (manifest *Manifest) MoveEntry(to, from int) error { + curLen := len(manifest.entries) + if (to < 0) || (to >= curLen) || (from < 0) || (from >= curLen) { + return errIndexOutOfBounds + } + manifest.changeAndNotify(func() { + entry := manifest.entries[from] + if from >= to { + copy(manifest.entries[to+1:from+1], manifest.entries[to:from]) + } else { + copy(manifest.entries[from:to], manifest.entries[from+1:to+1]) + } + manifest.entries[to] = entry + }, manifest.listIDs(manifest.entries[from])) + return nil +} + +// Filter finds all resources in the world that match the given parameters. +func (manifest Manifest) Filter(lang resource.Language, id resource.ID) resource.List { + var list resource.List + for _, entry := range manifest.entries { + list = list.Joined(entry.Resources.Filter(lang, id)) + } + return list +} + +// LocalizedResources produces a selector to retrieve resources for a specific language from the manifest. +// The returned selector has the strategy to merge the typical compound resource lists, such +// as the small textures, or string lookups. It is based on StandardResourceViewStrategy(). +func (manifest *Manifest) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + Lang: lang, + From: manifest, + As: ResourceViewStrategy(), + } +} + +// ObjectProperties returns the table of object properties. +func (manifest *Manifest) ObjectProperties() object.PropertiesTable { + var table object.PropertiesTable + for _, entry := range manifest.entries { + if len(entry.ObjectProperties) > 0 { + table = entry.ObjectProperties + } + } + return table +} + +// TextureProperties returns the table of texture properties. +func (manifest *Manifest) TextureProperties() texture.PropertiesList { + var list texture.PropertiesList + for _, entry := range manifest.entries { + if len(entry.TextureProperties) > 0 { + list = entry.TextureProperties + } + } + return list +} + +func (manifest *Manifest) listIDs(entries ...*ManifestEntry) (ids []resource.ID) { + for _, entry := range entries { + for _, res := range entry.Resources { + singleIDs := res.Viewer.IDs() + ids = append(ids, singleIDs...) + } + } + return +} + +func (manifest *Manifest) changeAndNotify(modifier func(), affectedIDs ...[]resource.ID) { + manifest.resourceChangeNotifier.ModifyAndNotify(modifier, affectedIDs...) +} diff --git a/modding/hacked/ss1/world/ManifestEntry.go b/modding/hacked/ss1/world/ManifestEntry.go new file mode 100644 index 0000000..62a6163 --- /dev/null +++ b/modding/hacked/ss1/world/ManifestEntry.go @@ -0,0 +1,53 @@ +package world + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// ManifestEntry describes a set of localized resources under a collective identifier. +type ManifestEntry struct { + Origin []string + ID string + Resources resource.LocalizedResourcesList + + ObjectProperties object.PropertiesTable + TextureProperties texture.PropertiesList +} + +// NewManifestEntryFrom attempts to create a manifest in memory from the given set of files. +// It uses LoadFiles() to load the files with given filenames into memory, allowing archives as well. +func NewManifestEntryFrom(names []string) (*ManifestEntry, error) { + loaded := LoadFiles(true, names) + + if len(loaded.Resources) == 0 { + return nil, errNoResourcesFound + } + + entry := &ManifestEntry{ + Origin: names, + ID: names[0], + } + + for location, viewer := range loaded.Resources { + localized := resource.LocalizedResources{ + ID: location.Name, + Language: ids.LocalizeFilename(location.Name), + Viewer: viewer, + } + entry.Resources = append(entry.Resources, localized) + } + entry.ObjectProperties = loaded.ObjectProperties + entry.TextureProperties = loaded.TextureProperties + return entry, nil +} + +// LocalizedResources produces a selector to retrieve resources for a specific language from this entry. +func (entry ManifestEntry) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + From: entry.Resources, + Lang: lang, + } +} diff --git a/modding/hacked/ss1/world/ManifestEntry_test.go b/modding/hacked/ss1/world/ManifestEntry_test.go new file mode 100644 index 0000000..56384ac --- /dev/null +++ b/modding/hacked/ss1/world/ManifestEntry_test.go @@ -0,0 +1,92 @@ +package world_test + +import ( + "fmt" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ManifestEntrySuite struct { + suite.Suite + entry *world.ManifestEntry + finder resource.Selector +} + +func TestManifestEntrySuite(t *testing.T) { + suite.Run(t, new(ManifestEntrySuite)) +} + +func (suite *ManifestEntrySuite) SetupTest() { + suite.entry = new(world.ManifestEntry) +} + +func (suite *ManifestEntrySuite) TestResourceReturnsErrorIfNothingFound() { + suite.whenViewingResource(resource.LangAny) + suite.thenResourceViewShouldNotBeAvailable(1234) +} + +func (suite *ManifestEntrySuite) TestResourceReturnsViewIfMatched() { + suite.givenLocalizedResources(resource.LangAny, 1000, [][]byte{{0x01}}) + suite.whenViewingResource(resource.LangAny) + suite.thenResourceViewShouldBeAvailable(1000) +} + +func (suite *ManifestEntrySuite) TestResourceConsidersResourcesWithLanguageAnyWhenLookingForSpecific() { + suite.givenLocalizedResources(resource.LangAny, 1000, [][]byte{{0x01}}) + suite.whenViewingResource(resource.LangDefault) + suite.thenResourceViewShouldBeAvailable(1000) +} + +func (suite *ManifestEntrySuite) TestResourceConsidersResourcesWithSpecificLanguage() { + suite.givenLocalizedResources(resource.LangFrench, 1000, [][]byte{{0x01}}) + suite.whenViewingResource(resource.LangFrench) + suite.thenResourceViewShouldBeAvailable(1000) +} + +func (suite *ManifestEntrySuite) TestResourceIgnoresResourcesWithSpecificLanguageWhenLookingForAny() { + suite.givenLocalizedResources(resource.LangFrench, 1000, [][]byte{{0x01}}) + suite.whenViewingResource(resource.LangAny) + suite.thenResourceViewShouldNotBeAvailable(1000) +} + +func (suite *ManifestEntrySuite) TestResourceIgnoresResourcesWithSpecificLanguageWhenLookingForDifferentLanguage() { + suite.givenLocalizedResources(resource.LangFrench, 1000, [][]byte{{0x01}}) + suite.whenViewingResource(resource.LangDefault) + suite.thenResourceViewShouldNotBeAvailable(1000) + + suite.whenViewingResource(resource.LangGerman) + suite.thenResourceViewShouldNotBeAvailable(1000) +} + +func (suite *ManifestEntrySuite) givenLocalizedResources(lang resource.Language, id int, data [][]byte) { + var store resource.Store + _ = store.Put(resource.ID(id), resource.Resource{ + Blocks: resource.BlocksFrom(data), + }) + suite.entry.Resources = append(suite.entry.Resources, resource.LocalizedResources{ + Language: lang, + Viewer: store, + }) +} + +func (suite *ManifestEntrySuite) whenViewingResource(lang resource.Language) { + suite.finder = suite.entry.LocalizedResources(lang) +} + +func (suite *ManifestEntrySuite) thenResourceViewShouldNotBeAvailable(id int) { + view, err := suite.finder.Select(resource.ID(id)) + assert.Error(suite.T(), err, fmt.Sprintf("Error expected finding resource %v", id)) + assert.Nil(suite.T(), view, fmt.Sprintf("No view expected finding resource %v", id)) +} + +func (suite *ManifestEntrySuite) thenResourceViewShouldBeAvailable(id int) { + view, err := suite.finder.Select(resource.ID(id)) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected finding resource %v", id)) + assert.NotNil(suite.T(), view, fmt.Sprintf("View expected for resource %v", id)) +} diff --git a/modding/hacked/ss1/world/Manifest_test.go b/modding/hacked/ss1/world/Manifest_test.go new file mode 100644 index 0000000..bb92282 --- /dev/null +++ b/modding/hacked/ss1/world/Manifest_test.go @@ -0,0 +1,478 @@ +package world_test + +import ( + "fmt" + "sort" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ManifestSuite struct { + suite.Suite + manifest *world.Manifest + selector *resource.Selector + + lastModifiedIDs []resource.ID + lastFailedIDs []resource.ID +} + +func TestManifestSuite(t *testing.T) { + suite.Run(t, new(ManifestSuite)) +} + +func (suite *ManifestSuite) SetupTest() { + suite.manifest = world.NewManifest(suite.onManifestModified) + suite.selector = nil + + suite.lastModifiedIDs = nil + suite.lastFailedIDs = nil +} + +func (suite *ManifestSuite) TestEntriesCanBeAdded() { + suite.whenEntryIsInserted(0, "id1") + suite.thenEntryCountShouldBe(1) + suite.thenEntryAtShouldBe(0, "id1") +} + +func (suite *ManifestSuite) TestMultipleEntriesCanBeAdded() { + suite.whenEntriesAreInserted(0, "id1", "id2", "id3") + suite.thenEntryCountShouldBe(3) + suite.thenEntryAtShouldBe(0, "id1") + suite.thenEntryAtShouldBe(1, "id2") + suite.thenEntryAtShouldBe(2, "id3") +} + +func (suite *ManifestSuite) TestEntryReturnsErrorOnInvalidInput() { + suite.givenEntryWasInserted(0, "id1") + + assertErrorAt := func(at int, message string) { + _, err := suite.manifest.Entry(at) + assert.Error(suite.T(), err, message) + } + assertErrorAt(-1, "Error expected with negative index") + assertErrorAt(1, "Error expected with index beyond limit") +} + +func (suite *ManifestSuite) TestEntriesCanBeInserted() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.whenEntryIsInserted(1, "id3") + suite.thenEntryCountShouldBe(3) + suite.thenEntryAtShouldBe(0, "id1") + suite.thenEntryAtShouldBe(1, "id3") + suite.thenEntryAtShouldBe(2, "id2") +} + +func (suite *ManifestSuite) TestMultipleEntriesCanBeInserted() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.whenEntriesAreInserted(1, "id3", "id4") + suite.thenEntryCountShouldBe(4) + suite.thenEntryAtShouldBe(0, "id1") + suite.thenEntryAtShouldBe(1, "id3") + suite.thenEntryAtShouldBe(2, "id4") + suite.thenEntryAtShouldBe(3, "id2") +} + +func (suite *ManifestSuite) TestInsertReturnsErrorsOnInvalidData() { + assert.Error(suite.T(), suite.manifest.InsertEntry(-1, suite.aSimpleEntry("-1")), "Error expected with negative index") + assert.Error(suite.T(), suite.manifest.InsertEntry(1, suite.aSimpleEntry("1")), "Error expected with index beyond count") + assert.Error(suite.T(), suite.manifest.InsertEntry(0, nil), "Error expected for nil entry") + assert.Error(suite.T(), suite.manifest.InsertEntry(0, suite.aSimpleEntry("1"), nil, suite.aSimpleEntry("2")), "Error expected for nil entry") +} + +func (suite *ManifestSuite) TestEntriesCanBeRemoved() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.givenEntryWasInserted(2, "id3") + suite.whenEntryIsRemoved(1) + suite.thenEntryCountShouldBe(2) + suite.thenEntryAtShouldBe(0, "id1") + suite.thenEntryAtShouldBe(1, "id3") + + suite.whenEntryIsRemoved(1) + suite.thenEntryCountShouldBe(1) + suite.thenEntryAtShouldBe(0, "id1") +} + +func (suite *ManifestSuite) TestRemoveReturnsErrorOnInvalidData() { + suite.givenEntryWasInserted(0, "id1") + + assert.Error(suite.T(), suite.manifest.RemoveEntry(-1), "Error expected with negative index") + assert.Error(suite.T(), suite.manifest.RemoveEntry(1), "Error expected with index beyond count") +} + +func (suite *ManifestSuite) TestEntriesCanBeReplaced() { + suite.givenEntryWasInserted(0, "id1") + suite.whenEntryIsReplaced(0, "id1-new") + suite.thenEntryAtShouldBe(0, "id1-new") +} + +func (suite *ManifestSuite) TestReplaceReturnsErrorOnInvalidData() { + suite.givenEntryWasInserted(0, "id1") + + assert.Error(suite.T(), suite.manifest.ReplaceEntry(-1, suite.aSimpleEntry("-1")), "Error expected with negative index") + assert.Error(suite.T(), suite.manifest.ReplaceEntry(1, suite.aSimpleEntry("1")), "Error expected with index beyond count") + assert.Error(suite.T(), suite.manifest.ReplaceEntry(0, nil), "Error expected for nil entry") +} + +func (suite *ManifestSuite) TestEntriesCanBeMovedBackward() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.givenEntryWasInserted(2, "id3") + suite.givenEntryWasInserted(3, "id4") + suite.givenEntryWasInserted(4, "id5") + suite.whenEntryIsMoved(1, 3) + suite.thenEntryOrderShouldBe("id1", "id4", "id2", "id3", "id5") +} + +func (suite *ManifestSuite) TestEntriesCanBeMovedForward() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.givenEntryWasInserted(2, "id3") + suite.givenEntryWasInserted(3, "id4") + suite.givenEntryWasInserted(4, "id5") + suite.whenEntryIsMoved(3, 1) + suite.thenEntryOrderShouldBe("id1", "id3", "id4", "id2", "id5") +} + +func (suite *ManifestSuite) TestMoveEntryToSamePlaceDoesNothing() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.givenEntryWasInserted(2, "id3") + suite.whenEntryIsMoved(1, 1) + suite.thenEntryOrderShouldBe("id1", "id2", "id3") +} + +func (suite *ManifestSuite) TestMoveEntryToEnd() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.givenEntryWasInserted(2, "id3") + suite.whenEntryIsMoved(2, 0) + suite.thenEntryOrderShouldBe("id2", "id3", "id1") +} + +func (suite *ManifestSuite) TestMoveEntryReturnsErrorOnInvalidInput() { + suite.givenEntryWasInserted(0, "id1") + suite.givenEntryWasInserted(1, "id2") + suite.givenEntryWasInserted(2, "id3") + + assert.Error(suite.T(), suite.manifest.MoveEntry(0, -1), "Error expected moving from negative index") + assert.Error(suite.T(), suite.manifest.MoveEntry(0, 3), "Error expected moving from index beyond limit") + assert.Error(suite.T(), suite.manifest.MoveEntry(-1, 1), "Error expected moving to negative index") + assert.Error(suite.T(), suite.manifest.MoveEntry(3, 1), "Error expected moving to index beyond limit") +} + +func (suite *ManifestSuite) TestLocalizedResourcesCanBeRetrieved() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x1234, [][]byte{{0xAA}}))) + suite.givenEntryWasInserted(1, "id2") + suite.whenResourcesAreQueriedFor(resource.LangAny) + suite.thenResourcesCanBeSelected(0x1234) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnInsertFromEmptyListsNewIDs() { + suite.whenEntryIsInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x1000, [][]byte{{0x11}}), + suite.storing(0x2000, [][]byte{{0x22}}))) + suite.thenModifiedResourcesShouldBe([]int{0x1000, 0x2000}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnInsertFromEmptyListsNewIDsMultipleEntries() { + suite.whenEntriesAreInsertedWith(0, 10, + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x1000, [][]byte{{0x11}}), + suite.storing(0x2000, [][]byte{{0x22}})), + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x3000, [][]byte{{0x11}}), + suite.storing(0x4000, [][]byte{{0x22}}))) + suite.thenModifiedResourcesShouldBe([]int{0x1000, 0x2000, 0x3000, 0x4000}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnInsertWithNewOnesListsNewIDs() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenEntryIsInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0900, [][]byte{{0x11}}), + suite.storing(0x0A00, [][]byte{{0x22}}))) + suite.thenModifiedResourcesShouldBe([]int{0x0900, 0x0A00}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnInsertIgnoresIdenticalData() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenEntryIsInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.thenModifiedResourcesShouldBe([]int{}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnInsertWithChangedData() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenEntryIsInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xBB}}))) + suite.thenModifiedResourcesShouldBe([]int{0x0800}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnRemoveWithLostIDs() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.givenEntryWasInsertedWith(1, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0900, [][]byte{{0xBB}}))) + suite.whenEntryIsRemoved(0) + suite.thenModifiedResourcesShouldBe([]int{0x0800}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnRemoveIgnoresIdenticalData_RemovedBefore() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}, {0xBB}}))) + suite.givenEntryWasInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}, {0xBB}}))) + suite.whenEntryIsRemoved(0) + suite.thenModifiedResourcesShouldBe([]int{}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnRemoveIgnoresIdenticalData_RemovedAfter() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}, {0xBB}}))) + suite.givenEntryWasInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}, {0xBB}}))) + suite.whenEntryIsRemoved(1) + suite.thenModifiedResourcesShouldBe([]int{}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnRemoveWithChangedData() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.givenEntryWasInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xBB}}))) + suite.whenEntryIsRemoved(1) + suite.thenModifiedResourcesShouldBe([]int{0x0800}) +} + +func (suite *ManifestSuite) TestModifiedCallbackOnReplaceWithChangedData() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenEntryIsReplacedWith(0, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xBB}}))) + suite.thenModifiedResourcesShouldBe([]int{0x0800}) +} + +func (suite *ManifestSuite) TestModifiedCallbackWithEmptyListOnIdenticalData() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenEntryIsReplacedWith(0, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.thenModifiedResourcesShouldBe([]int{}) +} + +func (suite *ManifestSuite) TestModifiedCallbackWithChangedIDsOnReplace() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenEntryIsReplacedWith(0, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0900, [][]byte{{0xAA}}))) + suite.thenModifiedResourcesShouldBe([]int{0x0800, 0x0900}) +} + +func (suite *ManifestSuite) TestModifiedCallbackWithChangedIDsOnMove() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.givenEntryWasInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xBB}}))) + suite.givenEntryWasInsertedWith(2, "id3", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0900, [][]byte{{0x11}}))) + suite.whenEntryIsMoved(2, 0) + suite.thenModifiedResourcesShouldBe([]int{0x0800}) +} + +func (suite *ManifestSuite) TestReset() { + suite.givenEntryWasInsertedWith(0, "id1", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.givenEntryWasInsertedWith(1, "id2", + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0900, [][]byte{{0xBB}}))) + suite.whenManifestIsReset() + suite.thenModifiedResourcesShouldBe([]int{0x0800, 0x0900}) + suite.thenEntryCountShouldBe(0) +} + +func (suite *ManifestSuite) onManifestModified(modifiedIDs []resource.ID, failedIDs []resource.ID) { + suite.lastModifiedIDs = modifiedIDs + suite.lastFailedIDs = failedIDs +} + +func (suite *ManifestSuite) givenEntryWasInserted(at int, id string) { + suite.whenEntryIsInserted(at, id) +} + +func (suite *ManifestSuite) givenEntryWasInsertedWith(at int, id string, res ...resource.LocalizedResources) { + suite.whenEntryIsInsertedWith(at, id, res...) + suite.lastModifiedIDs = nil + suite.lastFailedIDs = nil +} + +func (suite *ManifestSuite) whenEntryIsInsertedWith(at int, id string, res ...resource.LocalizedResources) { + err := suite.manifest.InsertEntry(at, suite.anEntryWithResources(id, res...)) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected inserting entry at %d", at)) +} + +func (suite *ManifestSuite) whenEntriesAreInsertedWith(at int, idBase int, resList ...resource.LocalizedResources) { + entries := make([]*world.ManifestEntry, len(resList)) + for index, res := range resList { + entries[index] = suite.anEntryWithResources(fmt.Sprintf("id%d", idBase+index), res) + } + err := suite.manifest.InsertEntry(at, entries...) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected inserting entry at %d", at)) +} + +func (suite *ManifestSuite) whenEntryIsInserted(at int, id string) { + err := suite.manifest.InsertEntry(at, suite.aSimpleEntry(id)) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected inserting entry at %d", at)) +} + +func (suite *ManifestSuite) whenEntriesAreInserted(at int, ids ...string) { + entries := make([]*world.ManifestEntry, len(ids)) + for index, id := range ids { + entries[index] = suite.aSimpleEntry(id) + } + err := suite.manifest.InsertEntry(at, entries...) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected inserting entry at %d", at)) +} + +func (suite *ManifestSuite) whenEntryIsRemoved(at int) { + err := suite.manifest.RemoveEntry(at) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected removing entry at %d", at)) +} + +func (suite *ManifestSuite) whenEntryIsReplaced(at int, id string) { + err := suite.manifest.ReplaceEntry(at, suite.aSimpleEntry(id)) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected replacing entry at %d", at)) +} + +func (suite *ManifestSuite) whenEntryIsReplacedWith(at int, id string, res ...resource.LocalizedResources) { + err := suite.manifest.ReplaceEntry(at, suite.anEntryWithResources(id, res...)) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected replacing entry at %d", at)) +} + +func (suite *ManifestSuite) whenEntryIsMoved(to int, from int) { + err := suite.manifest.MoveEntry(to, from) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected moving entry to %d from %d", to, from)) +} + +func (suite *ManifestSuite) whenResourcesAreQueriedFor(lang resource.Language) { + selector := suite.manifest.LocalizedResources(lang) + suite.selector = &selector +} + +func (suite *ManifestSuite) whenManifestIsReset() { + suite.manifest.Reset() +} + +func (suite *ManifestSuite) thenEntryCountShouldBe(expected int) { + assert.Equal(suite.T(), expected, suite.manifest.EntryCount()) +} + +func (suite *ManifestSuite) thenEntryAtShouldBe(at int, id string) { + entry, err := suite.manifest.Entry(at) + assert.Nil(suite.T(), err, fmt.Sprintf("No error expected retrieving entry at %d", at)) + require.NotNil(suite.T(), entry, fmt.Sprintf("Entry expected at %d", at)) + assert.Equal(suite.T(), id, entry.ID, fmt.Sprintf("Wrong entry found at %d", at)) +} + +func (suite *ManifestSuite) thenEntryOrderShouldBe(expected ...string) { + expectedLen := len(expected) + require.Equal(suite.T(), expectedLen, suite.manifest.EntryCount(), "Invalid number of entries") + currentIDs := make([]string, expectedLen) + for index := 0; index < expectedLen; index++ { + entry, err := suite.manifest.Entry(index) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected retrieving entry at %d", index)) + currentIDs[index] = entry.ID + } + assert.Equal(suite.T(), expected, currentIDs, "IDs don't not match") +} + +func (suite *ManifestSuite) thenResourcesCanBeSelected(id int) { + view, err := suite.selector.Select(resource.ID(id)) + assert.Nil(suite.T(), err, "No error expected") + assert.NotNil(suite.T(), view, "View expected") +} + +func (suite *ManifestSuite) aSimpleEntry(id string) *world.ManifestEntry { + return &world.ManifestEntry{ + ID: id, + } +} + +func (suite *ManifestSuite) someLocalizedResources(lang resource.Language, modifiers ...func(*resource.Store)) resource.LocalizedResources { + var store resource.Store + for _, modifier := range modifiers { + modifier(&store) + } + return resource.LocalizedResources{ + ID: "unnamed", + Language: lang, + Viewer: store, + } +} + +func (suite *ManifestSuite) anEntryWithResources(id string, res ...resource.LocalizedResources) *world.ManifestEntry { + return &world.ManifestEntry{ + ID: id, + Resources: res, + } +} + +func (suite *ManifestSuite) storing(id int, data [][]byte) func(*resource.Store) { + return func(store *resource.Store) { + _ = store.Put(resource.ID(id), resource.Resource{ + Blocks: resource.BlocksFrom(data), + }) + } +} + +func (suite *ManifestSuite) thenModifiedResourcesShouldBe(expected []int) { + identified := make([]resource.ID, len(expected)) + for index, id := range expected { + identified[index] = resource.ID(id) + } + suite.sortIDs(identified) + suite.sortIDs(suite.lastModifiedIDs) + + assert.Equal(suite.T(), identified, suite.lastModifiedIDs, "Modified IDs don't match") +} + +func (suite *ManifestSuite) sortIDs(ids []resource.ID) { + sort.Slice(ids, func(a, b int) bool { return ids[a] < ids[b] }) +} diff --git a/modding/hacked/ss1/world/Mod.go b/modding/hacked/ss1/world/Mod.go new file mode 100644 index 0000000..786fed9 --- /dev/null +++ b/modding/hacked/ss1/world/Mod.go @@ -0,0 +1,324 @@ +package world + +import ( + "bytes" + "path/filepath" + "sort" + "time" + + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial/rle" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// ModResetCallback is called when the mod was reset. +type ModResetCallback func() + +// Mod is the central object for a game-mod. +// +// It is based on a "static" world and adds its own changes. The world data itself is not static, it is merely the +// unchangeable background for the mod. Changes to the mod are kept in a separate layer, which can be loaded and saved. +type Mod struct { + worldManifest *Manifest + resourcesChanged resource.ModificationCallback + resetCallback ModResetCallback + + lastChangeTime time.Time + changedFiles map[string]struct{} + + data ModData +} + +// NewMod returns a new instance. +func NewMod(resourcesChanged resource.ModificationCallback, resetCallback ModResetCallback) *Mod { + mod := &Mod{ + resourcesChanged: resourcesChanged, + resetCallback: resetCallback, + changedFiles: make(map[string]struct{}), + } + mod.worldManifest = NewManifest(mod.worldChanged) + mod.data.FileChangeCallback = mod.markFileChanged + + return mod +} + +// World returns the static background to the mod. Changes in the returned manifest may cause change callbacks +// being forwarded. +func (mod Mod) World() *Manifest { + return mod.worldManifest +} + +// ModifiedResources returns the current modification state. +func (mod Mod) ModifiedResources() []*LocalizedResources { + return mod.data.LocalizedResources +} + +// ModifiedFilenames returns the list of all filenames suspected of change. +func (mod Mod) ModifiedFilenames() []string { + result := make([]string, 0, len(mod.changedFiles)) + for filename := range mod.changedFiles { + result = append(result, filename) + } + return result +} + +// AllAbsoluteFilenames returns the list of all filenames currently loaded in the mod. +func (mod Mod) AllAbsoluteFilenames(reference string) []string { + var result []string + for _, res := range mod.data.LocalizedResources { + result = append(result, res.File.AbsolutePathFrom(reference)) + } + if len(mod.data.ObjectProperties) > 0 { + result = append(result, filepath.Join(reference, ObjectPropertiesFilename)) + } + if len(mod.data.TextureProperties) > 0 { + result = append(result, filepath.Join(reference, TexturePropertiesFilename)) + } + sort.Strings(result) + return result +} + +// LastChangeTime returns the timestamp of the last change. Zero if not modified. +func (mod *Mod) LastChangeTime() time.Time { + return mod.lastChangeTime +} + +// ResetLastChangeTime clears the last change timestamp. It will be set again at the next modification. +func (mod *Mod) ResetLastChangeTime() { + mod.lastChangeTime = time.Time{} +} + +// MarkSave clears the list of modified filenames. +func (mod *Mod) MarkSave() { + mod.changedFiles = make(map[string]struct{}) + mod.lastChangeTime = time.Time{} +} + +// ModifiedResource retrieves the resource of given language and ID. +// There is no fallback lookup, it will return the exact resource stored under the provided identifier. +// Returns nil if the resource does not exist. +func (mod Mod) ModifiedResource(lang resource.Language, id resource.ID) resource.View { + return mod.modifiedResource(lang, id) +} + +func (mod Mod) modifiedResource(lang resource.Language, id resource.ID) *resource.Resource { + for _, entry := range mod.data.LocalizedResources { + if entry.Language == lang { + res, err := entry.Store.Resource(id) + if err == nil { + return res + } + } + } + return nil +} + +// CreateBlockPatch creates delta information for a block witch static data length. +// The returned patch structure contains details for both modifying the current state to be equal the new state, +// as well as the reversal delta. These deltas are calculated using the rle compression package. +// The returned boolean indicates whether the data differs. This can be used to detect whether the patch is necessary. +// An error is returned if the resource or the block do not exist, or if the length of newData does not match that of the block. +// +// If no error is returned, both the patch and the boolean provide valid information - even if the data is equal. +func (mod Mod) CreateBlockPatch(lang resource.Language, id resource.ID, index int, newData []byte) (BlockPatch, bool, error) { + patch := BlockPatch{ + ID: id, + BlockIndex: -1, + BlockLength: 0, + } + res := mod.modifiedResource(lang, id) + if res == nil { + return patch, false, resource.ErrNotFound(id) + } + oldData, err := res.BlockRaw(index) + if err != nil { + return patch, false, err + } + if len(oldData) != len(newData) { + return patch, false, errBlockLengthMismatch + } + + forwardData := bytes.NewBuffer(nil) + err = rle.Compress(forwardData, newData, oldData) + if err != nil { + return patch, false, err + } + patch.ForwardData = forwardData.Bytes() + + reverseData := bytes.NewBuffer(nil) + err = rle.Compress(reverseData, oldData, newData) + if err != nil { + return patch, false, err + } + patch.ReverseData = reverseData.Bytes() + + patch.BlockIndex = index + patch.BlockLength = len(oldData) + + return patch, !bytes.Equal(oldData, newData), nil +} + +// ModifiedBlock retrieves the specific block identified by given parameter. +// Returns empty slice if the block (or resource) is not modified. +func (mod Mod) ModifiedBlock(lang resource.Language, id resource.ID, index int) (data []byte) { + res := mod.modifiedResource(lang, id) + if res == nil { + return + } + raw, _ := res.BlockRaw(index) + return mod.blockCopy(raw) +} + +// ModifiedBlocks returns all blocks of the modified resource. +func (mod Mod) ModifiedBlocks(lang resource.Language, id resource.ID) [][]byte { + res := mod.modifiedResource(lang, id) + if res == nil { + return nil + } + data := make([][]byte, res.BlockCount()) + for index := 0; index < res.BlockCount(); index++ { + raw, _ := res.BlockRaw(index) + data[index] = mod.blockCopy(raw) + } + return data +} + +func (mod Mod) blockCopy(data []byte) []byte { + result := make([]byte, len(data)) + copy(result, data) + return result +} + +// Filter returns a list of resources that match the given parameters. +func (mod Mod) Filter(lang resource.Language, id resource.ID) resource.List { + list := mod.worldManifest.Filter(lang, id) + if res := mod.modifiedResource(resource.LangAny, id); res != nil { + list = list.With(res) + } + for _, worldLang := range resource.Languages() { + if worldLang.Includes(lang) { + if res := mod.modifiedResource(lang, id); res != nil { + list = list.With(res) + } + } + } + return list +} + +// LocalizedResources returns a resource selector for a specific language. +func (mod Mod) LocalizedResources(lang resource.Language) resource.Selector { + return resource.Selector{ + Lang: lang, + From: mod, + As: ResourceViewStrategy(), + } +} + +// Modify requests to change the mod. The provided function will be called to collect all changes. +// After the modifier completes, all the requests will be applied and any changes notified. +func (mod *Mod) Modify(modifier func(Modder)) { + var trans ModTransaction + modifier(&trans) + mod.modifyAndNotify(func() { + for _, action := range trans.actions { + action(&mod.data) + } + }, trans.modifiedIDs.ToList()) +} + +// ObjectProperties returns the table of object properties. +func (mod *Mod) ObjectProperties() object.PropertiesTable { + if mod.HasModifiableObjectProperties() { + return mod.data.ObjectProperties + } + return mod.worldManifest.ObjectProperties() +} + +// HasModifiableObjectProperties returns true if the mod has dedicated object properties. +func (mod *Mod) HasModifiableObjectProperties() bool { + return len(mod.data.ObjectProperties) > 0 +} + +// TextureProperties returns the list of texture properties. +func (mod *Mod) TextureProperties() texture.PropertiesList { + if mod.HasModifiableTextureProperties() { + return mod.data.TextureProperties + } + return mod.worldManifest.TextureProperties() +} + +// HasModifiableTextureProperties returns true if the mod has dedicated texture properties. +func (mod *Mod) HasModifiableTextureProperties() bool { + return len(mod.data.TextureProperties) > 0 +} + +func (mod *Mod) modifyAndNotify(modifier func(), modifiedIDs []resource.ID) { + notifier := resource.ChangeNotifier{ + Callback: mod.resourcesChanged, + Localizer: mod, + } + notifier.ModifyAndNotify(modifier, modifiedIDs) +} + +func (mod Mod) worldChanged(modifiedIDs []resource.ID, failedIDs []resource.ID) { + // It would be great to also check whether the mod hides any of these changes. + // Sadly, this is not possible: + // a) At the point of this callback, we can't do a check on the previous state anymore. + // b) Even when changing the world only within a modification enclosure of our own notifier, we can't determine + // the list of changed IDs before actually changing them. (Specifying ALL IDs is not a good idea due to performance.) + // As a result, simply forward this list. I don't even expect any big performance gain through such a filter. + // This would only be relevant to "full conversion" mods AND a change in a big list in the world. Hardly the case. + mod.resourcesChanged(modifiedIDs, failedIDs) +} + +// Reset changes the mod to a new set of resources. +func (mod *Mod) Reset(newResources []*LocalizedResources, objectProperties object.PropertiesTable, textureProperties texture.PropertiesList) { + var modifiedIDs resource.IDMarkerMap + collectIDs := func(res []*LocalizedResources) { + for _, loc := range res { + for _, id := range loc.Store.IDs() { + modifiedIDs.Add(id) + } + } + } + collectIDs(mod.data.LocalizedResources) + collectIDs(newResources) + + mod.data.LocalizedResources = newResources + mod.data.ObjectProperties = objectProperties + mod.data.TextureProperties = textureProperties + mod.changedFiles = make(map[string]struct{}) + mod.lastChangeTime = time.Time{} + mod.resetCallback() + mod.resourcesChanged(modifiedIDs.ToList(), nil) +} + +func (mod *Mod) markFileChanged(filename string) { + mod.changedFiles[filename] = struct{}{} + mod.lastChangeTime = time.Now() +} + +// FixListResources ensures all resources that contain resource lists to +// have maximum size. This is done to ensure compatibility with layered modding in the +// Source Port branch of engines. +// These engines will have "lower" mods bleed through only if the block is empty in a +// "higher" mod. This even counts for entries past the last modified one in the higher mod. +func (mod *Mod) FixListResources() { + mod.Modify(func(modder Modder) { + for _, localized := range mod.data.LocalizedResources { + for _, id := range localized.Store.IDs() { + res, _ := localized.Store.Resource(id) + info, known := ids.Info(id) + if known && info.List { + baseCount := res.BlockCount() + required := info.MaxCount - baseCount + for i := 0; i < required; i++ { + modder.SetResourceBlock(localized.Language, id, baseCount+i, nil) + } + } + } + } + }) +} diff --git a/modding/hacked/ss1/world/ModData.go b/modding/hacked/ss1/world/ModData.go new file mode 100644 index 0000000..7082ae5 --- /dev/null +++ b/modding/hacked/ss1/world/ModData.go @@ -0,0 +1,154 @@ +package world + +import ( + "bytes" + + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/serial/rle" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// LocalizedResources associates a language with a resource store under a specific filename. +type LocalizedResources struct { + // File describes from which file the data was possibly loaded from, and where it should be stored in. + File FileLocation + // Template specifies the "original" filename the data would be stored in. + // For example, savegames have "archive.dat" as their template. + // Note: This could be expanded to be a list. The loader could check which IDs are all stored in the loaded file, + // and match all corresponding templates. Though, this works only for "Any" language resources. + Template string + // Language identifies the language this package of resources is for. + Language resource.Language + // Store contains all the resources. + Store resource.Store +} + +// ModData contains the core information about a mod. +type ModData struct { + FileChangeCallback func(string) + + LocalizedResources []*LocalizedResources + ObjectProperties object.PropertiesTable + TextureProperties texture.PropertiesList +} + +// SetResourceBlock changes the block data of a resource. +// +// If the block data is not empty, then: +// If the resource does not exist, it will be created with default meta information. +// If the block does not exist, the resource is extended to allow its addition. +// +// If the block data is empty (or nil), then the block is cleared. +func (data *ModData) SetResourceBlock(lang resource.Language, id resource.ID, index int, blockData []byte) { + loc, res := data.ensureResource(lang, id) + res.SetBlock(index, blockData) + data.notifyFileChanged(loc.File.Name) +} + +// PatchResourceBlock modifies an existing block. +// This modification assumes the block already exists and can take the given patch data. +// The patch data is expected to be produced by rle.Compress(). +func (data *ModData) PatchResourceBlock(lang resource.Language, id resource.ID, index int, expectedLength int, patch []byte) { + loc, res := data.ensureResource(lang, id) + raw, err := res.BlockRaw(index) + if (err == nil) && (len(raw) == expectedLength) { + _ = rle.Decompress(bytes.NewReader(patch), raw) + data.notifyFileChanged(loc.File.Name) + } +} + +// SetResourceBlocks sets the entire list of block data of a resource. +// This method is primarily meant for compound non-list resources (e.g. text pages). +func (data *ModData) SetResourceBlocks(lang resource.Language, id resource.ID, blocks [][]byte) { + loc, res := data.ensureResource(lang, id) + res.Set(blocks) + data.notifyFileChanged(loc.File.Name) +} + +// DelResource removes a resource from the mod in the given language. +func (data *ModData) DelResource(lang resource.Language, id resource.ID) { + for _, loc := range data.LocalizedResources { + if (loc.Language == lang) && loc.Store.Del(id) { + data.notifyFileChanged(loc.File.Name) + } + } +} + +// SetTextureProperties updates the properties of a specific texture. +func (data *ModData) SetTextureProperties(index int, properties texture.Properties) { + if (index >= 0) && (index < len(data.TextureProperties)) { + data.TextureProperties[index] = properties + data.notifyFileChanged(TexturePropertiesFilename) + } +} + +// SetObjectProperties updates the properties of a specific object. +func (data *ModData) SetObjectProperties(triple object.Triple, properties object.Properties) { + entry, err := data.ObjectProperties.ForObject(triple) + if err != nil { + return + } + *entry = properties.Clone() + data.notifyFileChanged(ObjectPropertiesFilename) +} + +func (data *ModData) ensureResource(lang resource.Language, id resource.ID) (*LocalizedResources, *resource.Resource) { + for _, loc := range data.LocalizedResources { + if loc.Language == lang { + res, err := loc.Store.Resource(id) + if err == nil { + return loc, res + } + } + } + + return data.newResource(lang, id) +} + +func (data *ModData) newResource(lang resource.Language, id resource.ID) (*LocalizedResources, *resource.Resource) { + compound := true + contentType := resource.ContentType(0xFF) // Default to something completely unknown. + compressed := false + filename := "unknown.res" + + if info, known := ids.Info(id); known { + compound = info.Compound + contentType = info.ContentType + compressed = info.Compressed + filename = info.ResFile.For(lang) + } + + loc := data.ensureStore(lang, filename) + _ = loc.Store.Put(id, resource.Resource{ + Properties: resource.Properties{ + Compound: compound, + ContentType: contentType, + Compressed: compressed, + }, + }) + res, _ := loc.Store.Resource(id) + return loc, res +} + +func (data *ModData) ensureStore(lang resource.Language, filename string) *LocalizedResources { + for _, loc := range data.LocalizedResources { + if (loc.Language == lang) && (loc.Template == filename) { + return loc + } + } + loc := &LocalizedResources{ + File: FileLocation{DirPath: ".", Name: filename}, + Template: filename, + Language: lang, + } + data.LocalizedResources = append(data.LocalizedResources, loc) + return loc +} + +func (data ModData) notifyFileChanged(filename string) { + if data.FileChangeCallback != nil { + data.FileChangeCallback(filename) + } +} diff --git a/modding/hacked/ss1/world/ModTransaction.go b/modding/hacked/ss1/world/ModTransaction.go new file mode 100644 index 0000000..fc1ffc9 --- /dev/null +++ b/modding/hacked/ss1/world/ModTransaction.go @@ -0,0 +1,74 @@ +package world + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/resource" +) + +type modAction func(modder Modder) + +// ModTransaction is used to queue a list of modifications. +// It allows modifications of related resources in one atomic action. +type ModTransaction struct { + actions []modAction + modifiedIDs resource.IDMarkerMap +} + +// SetResourceBlock changes the block data of a resource. +// +// If the block data is not empty, then: +// If the resource does not exist, it will be created with default meta information. +// If the block does not exist, the resource is extended to allow its addition. +// +// If the block data is empty (or nil), then the block is cleared. +// If the resource is a compound list, then the underlying data will become visible again. +func (trans *ModTransaction) SetResourceBlock(lang resource.Language, id resource.ID, index int, data []byte) { + trans.actions = append(trans.actions, func(modder Modder) { + modder.SetResourceBlock(lang, id, index, data) + }) + trans.modifiedIDs.Add(id) +} + +// PatchResourceBlock modifies an existing block. +// This modification assumes the block already exists and can take the given patch data. +// The patch data is expected to be produced by rle.Compress(). +func (trans *ModTransaction) PatchResourceBlock(lang resource.Language, id resource.ID, index int, expectedLength int, patch []byte) { + trans.actions = append(trans.actions, func(modder Modder) { + modder.PatchResourceBlock(lang, id, index, expectedLength, patch) + }) + trans.modifiedIDs.Add(id) +} + +// SetResourceBlocks sets the entire list of block data of a resource. +// This method is primarily meant for compound non-list resources (e.g. text pages). +func (trans *ModTransaction) SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte) { + trans.actions = append(trans.actions, func(modder Modder) { + modder.SetResourceBlocks(lang, id, data) + }) + trans.modifiedIDs.Add(id) +} + +// DelResource removes a resource from the mod in the given language. +// +// After the deletion, all the underlying data of the world will become visible again. +func (trans *ModTransaction) DelResource(lang resource.Language, id resource.ID) { + trans.actions = append(trans.actions, func(modder Modder) { + modder.DelResource(lang, id) + }) + trans.modifiedIDs.Add(id) +} + +// SetTextureProperties updates the properties of a specific texture. +func (trans *ModTransaction) SetTextureProperties(textureIndex int, properties texture.Properties) { + trans.actions = append(trans.actions, func(modder Modder) { + modder.SetTextureProperties(textureIndex, properties) + }) +} + +// SetObjectProperties updates the properties of a specific object. +func (trans *ModTransaction) SetObjectProperties(triple object.Triple, properties object.Properties) { + trans.actions = append(trans.actions, func(modder Modder) { + modder.SetObjectProperties(triple, properties) + }) +} diff --git a/modding/hacked/ss1/world/Mod_test.go b/modding/hacked/ss1/world/Mod_test.go new file mode 100644 index 0000000..703b1e6 --- /dev/null +++ b/modding/hacked/ss1/world/Mod_test.go @@ -0,0 +1,235 @@ +package world_test + +import ( + "fmt" + "io/ioutil" + "sort" + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type ModSuite struct { + suite.Suite + mod *world.Mod + + selector *resource.Selector + + lastModifiedIDs []resource.ID + lastFailedIDs []resource.ID +} + +func TestModSuite(t *testing.T) { + suite.Run(t, new(ModSuite)) +} + +func (suite *ModSuite) SetupTest() { + suite.mod = world.NewMod(suite.onResourcesModified, func() {}) + + suite.selector = nil + + suite.lastModifiedIDs = nil + suite.lastFailedIDs = nil +} + +func (suite *ModSuite) onResourcesModified(modifiedIDs []resource.ID, failedIDs []resource.ID) { + suite.lastModifiedIDs = modifiedIDs + suite.lastFailedIDs = failedIDs +} + +func (suite *ModSuite) TestResourcesCanBeRetrievedFromTheWorld() { + suite.givenWorldHas( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenResourcesAreQueriedFor(resource.LangAny) + suite.thenResourcesCanBeSelected(0x0800) +} + +func (suite *ModSuite) TestResourcesCanBeModified() { + suite.givenWorldHas( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenModifyingBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 0, []byte{0xBB}) + }) + suite.thenResourceBlockShouldBe(resource.LangAny, 0x0800, 0, []byte{0xBB}) +} + +func (suite *ModSuite) TestResourcesCanBeExtended() { + suite.givenWorldHas( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenModifyingBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 2, []byte{0xBB}) + }) + suite.thenResourceBlockShouldBe(resource.LangAny, 0x0800, 2, []byte{0xBB}) +} + +func (suite *ModSuite) TestResourcesCanBeRemoved() { + suite.givenWorldHas( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.givenModifiedBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 0, []byte{0xBB}) + }) + suite.whenModifyingBy(func(modder world.Modder) { + modder.DelResource(resource.LangAny, 0x0800) + }) + suite.thenResourceBlockShouldBe(resource.LangAny, 0x0800, 0, []byte{0xAA}) +} + +func (suite *ModSuite) TestAdditionsAreNotified() { + suite.givenWorldHas( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.whenModifyingBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 2, []byte{0xBB}) + }) + suite.thenModifiedResourcesShouldBe(0x0800) +} + +func (suite *ModSuite) TestModificationsToIdenticalDataAreNotNotified() { + suite.givenWorldHas( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x004C, [][]byte{{0xAA}}))) + suite.whenModifyingBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x004C, 0, []byte{0xAA}) + }) + suite.thenModifiedResourcesShouldBe() +} + +func (suite *ModSuite) TestDeletionIsNotified() { + suite.givenWorldHas( + suite.someLocalizedResources(resource.LangAny, + suite.storing(0x0800, [][]byte{{0xAA}}))) + suite.givenModifiedBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 0, []byte{0xBB}) + }) + suite.whenModifyingBy(func(modder world.Modder) { + modder.DelResource(resource.LangAny, 0x0800) + }) + suite.thenModifiedResourcesShouldBe(0x0800) +} + +func (suite *ModSuite) TestModifiedResourceCanBeRetrieved() { + suite.whenModifyingBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 0, []byte{0xBB}) + modder.SetResourceBlock(resource.LangAny, 0x0800, 1, []byte{0xCC}) + }) + + res := suite.mod.ModifiedResource(resource.LangAny, 0x0800) + require.NotNil(suite.T(), res, "Resource expected") + assert.Equal(suite.T(), 2, res.BlockCount(), "Two blocks expected") +} + +func (suite *ModSuite) TestModifiedBlocksCanBeRetrievedSingle() { + suite.whenModifyingBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 0, []byte{0xBB}) + modder.SetResourceBlock(resource.LangAny, 0x0800, 1, []byte{0xCC}) + }) + + assert.Equal(suite.T(), []byte{0xBB}, suite.mod.ModifiedBlock(resource.LangAny, 0x0800, 0)) + assert.Equal(suite.T(), []byte{0xCC}, suite.mod.ModifiedBlock(resource.LangAny, 0x0800, 1)) +} + +func (suite *ModSuite) TestModifiedBlocksCanBeRetrievedBulk() { + suite.whenModifyingBy(func(modder world.Modder) { + modder.SetResourceBlock(resource.LangAny, 0x0800, 0, []byte{0xBB}) + modder.SetResourceBlock(resource.LangAny, 0x0800, 1, []byte{0xCC}) + }) + + assert.Equal(suite.T(), [][]byte{{0xBB}, {0xCC}}, suite.mod.ModifiedBlocks(resource.LangAny, 0x0800)) +} + +func (suite *ModSuite) givenWorldHas(res ...resource.LocalizedResources) { + suite.whenWorldIsExtendedWith(res...) + suite.lastModifiedIDs = nil + suite.lastFailedIDs = nil +} + +func (suite *ModSuite) whenWorldIsExtendedWith(res ...resource.LocalizedResources) { + manifest := suite.mod.World() + at := manifest.EntryCount() + id := fmt.Sprintf("entry-%d", at) + err := suite.mod.World().InsertEntry(at, suite.anEntryWithResources(id, res...)) + require.Nil(suite.T(), err, fmt.Sprintf("No error expected inserting entry at %d", at)) +} + +func (suite *ModSuite) whenResourcesAreQueriedFor(lang resource.Language) { + selector := suite.mod.LocalizedResources(lang) + suite.selector = &selector +} + +func (suite *ModSuite) givenModifiedBy(modifier func(world.Modder)) { + suite.mod.Modify(modifier) + + suite.lastModifiedIDs = nil + suite.lastFailedIDs = nil +} + +func (suite *ModSuite) whenModifyingBy(modifier func(world.Modder)) { + suite.mod.Modify(modifier) +} + +func (suite *ModSuite) thenResourceBlockShouldBe(lang resource.Language, id int, blockIndex int, expected []byte) { + view, viewErr := suite.mod.LocalizedResources(lang).Select(resource.ID(id)) + require.Nil(suite.T(), viewErr, "No error expected selecting resource") + reader, blockErr := view.Block(blockIndex) + require.Nil(suite.T(), blockErr, "No error expected retrieving block") + data, dataErr := ioutil.ReadAll(reader) + require.Nil(suite.T(), dataErr, "No error expected reading data") + assert.Equal(suite.T(), expected, data, "Data mismatch in block") +} + +func (suite *ModSuite) thenResourcesCanBeSelected(id int) { + view, err := suite.selector.Select(resource.ID(id)) + assert.Nil(suite.T(), err, "No error expected") + assert.NotNil(suite.T(), view, "View expected") +} + +func (suite *ModSuite) thenModifiedResourcesShouldBe(expected ...int) { + identified := make([]resource.ID, len(expected)) + for index, id := range expected { + identified[index] = resource.ID(id) + } + suite.sortIDs(identified) + suite.sortIDs(suite.lastModifiedIDs) + + assert.Equal(suite.T(), identified, suite.lastModifiedIDs, "Modified IDs don't match") +} + +func (suite *ModSuite) sortIDs(ids []resource.ID) { + sort.Slice(ids, func(a, b int) bool { return ids[a] < ids[b] }) +} + +func (suite *ModSuite) someLocalizedResources(lang resource.Language, modifiers ...func(*resource.Store)) resource.LocalizedResources { + var store resource.Store + for _, modifier := range modifiers { + modifier(&store) + } + return resource.LocalizedResources{ + ID: "unnamed", + Language: lang, + Viewer: store, + } +} + +func (suite *ModSuite) anEntryWithResources(id string, res ...resource.LocalizedResources) *world.ManifestEntry { + return &world.ManifestEntry{ + ID: id, + Resources: res, + } +} + +func (suite *ModSuite) storing(id int, data [][]byte) func(*resource.Store) { + return func(store *resource.Store) { + _ = store.Put(resource.ID(id), resource.Resource{ + Blocks: resource.BlocksFrom(data), + }) + } +} diff --git a/modding/hacked/ss1/world/Modder.go b/modding/hacked/ss1/world/Modder.go new file mode 100644 index 0000000..d369af4 --- /dev/null +++ b/modding/hacked/ss1/world/Modder.go @@ -0,0 +1,41 @@ +package world + +import ( + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/content/texture" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// Modder describes actions meant to modify resources. +type Modder interface { + + // SetResourceBlock changes the block data of a resource. + // + // If the block data is not empty, then: + // If the resource does not exist, it will be created with default meta information. + // If the block does not exist, the resource is extended to allow its addition. + // + // If the block data is empty (or nil), then the block is cleared. + // If the resource is a compound list, then the underlying data will become visible again. + SetResourceBlock(lang resource.Language, id resource.ID, index int, data []byte) + + // PatchResourceBlock modifies an existing block. + // This modification assumes the block already exists and can take the given patch data. + // The patch data is expected to be produced by rle.Compress(). (see also: Mod.CreateBlockPatch) + PatchResourceBlock(lang resource.Language, id resource.ID, index int, expectedLength int, patch []byte) + + // SetResourceBlocks sets the entire list of block data of a resource. + // This method is primarily meant for compound non-list resources (e.g. text pages). + SetResourceBlocks(lang resource.Language, id resource.ID, data [][]byte) + + // DelResource removes a resource from the mod in the given language. + // + // After the deletion, all the underlying data of the world will become visible again. + DelResource(lang resource.Language, id resource.ID) + + // SetTextureProperties updates the properties of a specific texture. + SetTextureProperties(textureIndex int, properties texture.Properties) + + // SetObjectProperties updates the properties of a specific object. + SetObjectProperties(triple object.Triple, properties object.Properties) +} diff --git a/modding/hacked/ss1/world/ResourceViewStrategy.go b/modding/hacked/ss1/world/ResourceViewStrategy.go new file mode 100644 index 0000000..796acc9 --- /dev/null +++ b/modding/hacked/ss1/world/ResourceViewStrategy.go @@ -0,0 +1,18 @@ +package world + +import ( + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" +) + +// ResourceViewStrategy returns a strategy that is typical for the game. +func ResourceViewStrategy() resource.ViewStrategy { + return defaultResources{} +} + +type defaultResources struct{} + +func (def defaultResources) IsCompoundList(id resource.ID) bool { + info, _ := ids.Info(id) + return info.List +} diff --git a/modding/hacked/ss1/world/citadel/GameState.go b/modding/hacked/ss1/world/citadel/GameState.go new file mode 100644 index 0000000..f7dd645 --- /dev/null +++ b/modding/hacked/ss1/world/citadel/GameState.go @@ -0,0 +1,39 @@ +package citadel + +import ( + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/world" +) + +// DefaultGameState returns a new instance of a game state that the engine creates for a standard game. +func DefaultGameState() *archive.GameState { + state := archive.DefaultGameState() + + // location: medical level + state.Set("Current Level", world.StartingLevel) + + // location: in the neurosurgery chamber, looking West + state.Set("Hacker Position X", (world.StartingTileX<<16)+0x8000) + state.Set("Hacker Position Y", (world.StartingTileY<<16)+0x8000) + state.Set("Hacker Position Z", 0x01BD00) + state.Set("Hacker Yaw", 0x03243E) + + // set first message + state.EMailState(26).SetReceived(true) // Rebecca Lansing's first message + // The engine also sets the active state of email, though this field index is not used. + + for index, info := range booleanVariables { + if info.InitValue == nil { + continue + } + state.SetBooleanVar(index, *info.InitValue != 0) + } + for index, info := range integerVariables { + if info.InitValue == nil { + continue + } + state.SetIntegerVar(index, *info.InitValue) + } + + return state +} diff --git a/modding/hacked/ss1/world/citadel/Variables.go b/modding/hacked/ss1/world/citadel/Variables.go new file mode 100644 index 0000000..ae7f62b --- /dev/null +++ b/modding/hacked/ss1/world/citadel/Variables.go @@ -0,0 +1,192 @@ +package citadel + +import "github.com/inkyblackness/hacked/ss1/content/archive" + +var integerVariables = archive.GameVariables{ + 3: archive.GameVariableInfoFor("Engine State").At(2).LimitedBy(0, 100), + + 12: archive.GameVariableInfoFor("Number of Groves").At(3).LimitedBy(0, 4), + + 33: archive.GameVariableInfoFor("Destroyed CPUs Level 1").At(0).LimitedBy(0, 100), + 34: archive.GameVariableInfoFor("Destroyed CPUs Level 2").At(0).LimitedBy(0, 100), + 35: archive.GameVariableInfoFor("Destroyed CPUs Level 3").At(0).LimitedBy(0, 100), + 36: archive.GameVariableInfoFor("Destroyed CPUs Level 4").At(0).LimitedBy(0, 100), + 37: archive.GameVariableInfoFor("Destroyed CPUs Level 5").At(0).LimitedBy(0, 100), + 38: archive.GameVariableInfoFor("Destroyed CPUs Level 6").At(0).LimitedBy(0, 100), + + 39: archive.GameVariableInfoFor("Killed Cyberguards, largce CSpace").At(0).LimitedBy(0, 100), +} + +var booleanVariables = archive.GameVariables{ + 1: archive.GameVariableInfoFor("(unknown)").At(1), + 2: archive.GameVariableInfoFor("(unknown)").At(1), + 3: archive.GameVariableInfoFor("(unknown)").At(1), + 16: archive.GameVariableInfoFor("(unknown)").At(1), + 18: archive.GameVariableInfoFor("(unknown)").At(1), + 21: archive.GameVariableInfoFor("(unknown)").At(1), + 22: archive.GameVariableInfoFor("(unknown)").At(1), + 23: archive.GameVariableInfoFor("(unknown)").At(1), + 24: archive.GameVariableInfoFor("(unknown)").At(1), + 25: archive.GameVariableInfoFor("(unknown)").At(1), + 26: archive.GameVariableInfoFor("(unknown)").At(1), + 32: archive.GameVariableInfoFor("(unknown)").At(1), + 33: archive.GameVariableInfoFor("(unknown)").At(1), + 36: archive.GameVariableInfoFor("(unknown)").At(1), + 37: archive.GameVariableInfoFor("(unknown)").At(1), + 75: archive.GameVariableInfoFor("(unknown)").At(1), + 76: archive.GameVariableInfoFor("(unknown)").At(1), + 77: archive.GameVariableInfoFor("(unknown)").At(1), + 78: archive.GameVariableInfoFor("(unknown)").At(1), + 79: archive.GameVariableInfoFor("(unknown)").At(1), + 80: archive.GameVariableInfoFor("(unknown)").At(1), + 81: archive.GameVariableInfoFor("(unknown)").At(1), + 82: archive.GameVariableInfoFor("(unknown)").At(1), + 83: archive.GameVariableInfoFor("(unknown)").At(1), + 84: archive.GameVariableInfoFor("(unknown)").At(1), + 85: archive.GameVariableInfoFor("(unknown)").At(1), + 86: archive.GameVariableInfoFor("(unknown)").At(1), + 87: archive.GameVariableInfoFor("(unknown)").At(1), + 88: archive.GameVariableInfoFor("(unknown)").At(1), + 89: archive.GameVariableInfoFor("(unknown)").At(1), + 90: archive.GameVariableInfoFor("(unknown)").At(1), + 91: archive.GameVariableInfoFor("(unknown)").At(1), + 92: archive.GameVariableInfoFor("(unknown)").At(1), + 93: archive.GameVariableInfoFor("(unknown)").At(1), + 94: archive.GameVariableInfoFor("(unknown)").At(1), + 95: archive.GameVariableInfoFor("(unknown)").At(1), + 112: archive.GameVariableInfoFor("(unknown)").At(1), + 113: archive.GameVariableInfoFor("(unknown)").At(1), + 114: archive.GameVariableInfoFor("(unknown)").At(1), + 115: archive.GameVariableInfoFor("(unknown)").At(1), + 116: archive.GameVariableInfoFor("(unknown)").At(1), + 117: archive.GameVariableInfoFor("(unknown)").At(1), + 118: archive.GameVariableInfoFor("(unknown)").At(1), + 119: archive.GameVariableInfoFor("(unknown)").At(1), + 120: archive.GameVariableInfoFor("(unknown)").At(1), + 121: archive.GameVariableInfoFor("(unknown)").At(1), + 122: archive.GameVariableInfoFor("(unknown)").At(1), + 123: archive.GameVariableInfoFor("(unknown)").At(1), + 124: archive.GameVariableInfoFor("(unknown)").At(1), + 125: archive.GameVariableInfoFor("(unknown)").At(1), + 126: archive.GameVariableInfoFor("(unknown)").At(1), + 127: archive.GameVariableInfoFor("(unknown)").At(1), + + 152: archive.GameVariableInfoFor("Bridge Separated").At(0), + + 160: archive.GameVariableInfoFor("(unknown)").At(1), + 161: archive.GameVariableInfoFor("(unknown)").At(1), + 162: archive.GameVariableInfoFor("(unknown)").At(1), + 163: archive.GameVariableInfoFor("(unknown)").At(1), + 164: archive.GameVariableInfoFor("(unknown)").At(1), + 165: archive.GameVariableInfoFor("(unknown)").At(1), + 166: archive.GameVariableInfoFor("(unknown)").At(1), + 167: archive.GameVariableInfoFor("(unknown)").At(1), + 168: archive.GameVariableInfoFor("(unknown)").At(1), + 169: archive.GameVariableInfoFor("(unknown)").At(1), + 192: archive.GameVariableInfoFor("(unknown)").At(1), + 193: archive.GameVariableInfoFor("(unknown)").At(1), + 194: archive.GameVariableInfoFor("(unknown)").At(1), + 195: archive.GameVariableInfoFor("(unknown)").At(1), + 196: archive.GameVariableInfoFor("(unknown)").At(1), + 197: archive.GameVariableInfoFor("(unknown)").At(1), + 198: archive.GameVariableInfoFor("(unknown)").At(1), + 199: archive.GameVariableInfoFor("(unknown)").At(1), + 200: archive.GameVariableInfoFor("(unknown)").At(1), + 201: archive.GameVariableInfoFor("(unknown)").At(1), + 202: archive.GameVariableInfoFor("(unknown)").At(1), + 203: archive.GameVariableInfoFor("(unknown)").At(1), + 204: archive.GameVariableInfoFor("(unknown)").At(1), + 205: archive.GameVariableInfoFor("(unknown)").At(1), + 206: archive.GameVariableInfoFor("(unknown)").At(1), + 207: archive.GameVariableInfoFor("(unknown)").At(1), + 225: archive.GameVariableInfoFor("(unknown)").At(1), + 227: archive.GameVariableInfoFor("(unknown)").At(1), + 229: archive.GameVariableInfoFor("(unknown)").At(1), + 231: archive.GameVariableInfoFor("(unknown)").At(1), + 233: archive.GameVariableInfoFor("(unknown)").At(1), + 235: archive.GameVariableInfoFor("(unknown)").At(1), + 237: archive.GameVariableInfoFor("(unknown)").At(1), + 239: archive.GameVariableInfoFor("(unknown)").At(1), + 241: archive.GameVariableInfoFor("(unknown)").At(1), + 243: archive.GameVariableInfoFor("(unknown)").At(1), + 245: archive.GameVariableInfoFor("(unknown)").At(1), + 247: archive.GameVariableInfoFor("(unknown)").At(1), + 249: archive.GameVariableInfoFor("(unknown)").At(1), + 251: archive.GameVariableInfoFor("(unknown)").At(1), + 253: archive.GameVariableInfoFor("(unknown)").At(1), + 255: archive.GameVariableInfoFor("(unknown)").At(1), + 257: archive.GameVariableInfoFor("(unknown)").At(1), + 259: archive.GameVariableInfoFor("(unknown)").At(1), + 261: archive.GameVariableInfoFor("(unknown)").At(1), + 263: archive.GameVariableInfoFor("(unknown)").At(1), + 265: archive.GameVariableInfoFor("(unknown)").At(1), + 267: archive.GameVariableInfoFor("(unknown)").At(1), + 269: archive.GameVariableInfoFor("(unknown)").At(1), + 271: archive.GameVariableInfoFor("(unknown)").At(1), + 273: archive.GameVariableInfoFor("(unknown)").At(1), + 275: archive.GameVariableInfoFor("(unknown)").At(1), + 277: archive.GameVariableInfoFor("(unknown)").At(1), + 279: archive.GameVariableInfoFor("(unknown)").At(1), + 281: archive.GameVariableInfoFor("(unknown)").At(1), + 283: archive.GameVariableInfoFor("(unknown)").At(1), + 285: archive.GameVariableInfoFor("(unknown)").At(1), + 287: archive.GameVariableInfoFor("(unknown)").At(1), + 289: archive.GameVariableInfoFor("(unknown)").At(1), + 291: archive.GameVariableInfoFor("(unknown)").At(1), + 293: archive.GameVariableInfoFor("(unknown)").At(1), + 295: archive.GameVariableInfoFor("(unknown)").At(1), + 297: archive.GameVariableInfoFor("(unknown)").At(1), + 299: archive.GameVariableInfoFor("(unknown)").At(1), +} + +// IntegerVariable returns a variable info for integer variables. +func IntegerVariable(index int) *archive.GameVariableInfo { + varInfo := integerVariables.Lookup(index) + if varInfo != nil { + return varInfo + } + varInfo = archive.EngineIntegerVariable(index) + if varInfo == nil { + return nil + } + + if index == 31 { + varInfo.Name = "Reactor Code 1" + } + if index == 32 { + varInfo.Name = "Reactor Code 2" + } + return varInfo +} + +// BooleanVariable returns a variable info for boolean variables. +func BooleanVariable(index int) *archive.GameVariableInfo { + varInfo := booleanVariables.Lookup(index) + if varInfo != nil { + return varInfo + } + return archive.EngineBooleanVariable(index) +} + +// MissionVariables is a collector of mission-specific variable accessors. +type MissionVariables struct{} + +var unknownVar = archive.GameVariableInfoFor("(unknown)").At(0) + +// IntegerVariable returns a variable info for given index. +func (vars MissionVariables) IntegerVariable(index int) archive.GameVariableInfo { + varInfo := IntegerVariable(index) + if varInfo == nil { + return unknownVar + } + return *varInfo +} + +// BooleanVariable returns a variable info for given index. +func (vars MissionVariables) BooleanVariable(index int) archive.GameVariableInfo { + varInfo := BooleanVariable(index) + if varInfo == nil { + return unknownVar + } + return *varInfo +} diff --git a/modding/hacked/ss1/world/doc.go b/modding/hacked/ss1/world/doc.go new file mode 100644 index 0000000..c92ef61 --- /dev/null +++ b/modding/hacked/ss1/world/doc.go @@ -0,0 +1,4 @@ +/* +Package world and its subpackages contain everything a typical simulated world requires. +*/ +package world diff --git a/modding/hacked/ss1/world/ids/Filename.go b/modding/hacked/ss1/world/ids/Filename.go new file mode 100644 index 0000000..73cd70a --- /dev/null +++ b/modding/hacked/ss1/world/ids/Filename.go @@ -0,0 +1,61 @@ +package ids + +import ( + "strings" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +// Filename defines a wrapper for a file that is possibly language-specific. +type Filename interface { + // For returns the name of the file for the given language. + For(lang resource.Language) string + + // Matches returns true if the given filename matches the described one. + Matches(filename string) bool +} + +// I18nFile is for internationalized resource files - i.e., those that store resources per file. +type I18nFile [resource.LanguageCount]string + +// For returns the string per language index. +func (spec I18nFile) For(lang resource.Language) string { + return spec[int(lang)] +} + +// Matches returns true if the given filename matches one of the localized filenames. +func (spec I18nFile) Matches(filename string) bool { + lowercase := strings.ToLower(filename) + for _, entry := range spec { + if entry == lowercase { + return true + } + } + return false +} + +// AnyLanguage is for generic resource files that are language agnostic. +type AnyLanguage string + +// For returns the string itself. +func (any AnyLanguage) For(lang resource.Language) string { + return string(any) +} + +// Matches returns true if the given filename matches this one. +func (any AnyLanguage) Matches(filename string) bool { + return strings.ToLower(filename) == string(any) +} + +// FilenameList is a list of filenames. +type FilenameList []Filename + +// Matches returns true if the given filename matches any of the contained entries. +func (list FilenameList) Matches(filename string) bool { + for _, entry := range list { + if entry.Matches(filename) { + return true + } + } + return false +} diff --git a/modding/hacked/ss1/world/ids/Filenames.go b/modding/hacked/ss1/world/ids/Filenames.go new file mode 100644 index 0000000..cc76e7b --- /dev/null +++ b/modding/hacked/ss1/world/ids/Filenames.go @@ -0,0 +1,127 @@ +package ids + +import ( + "strings" + + "github.com/inkyblackness/hacked/ss1/resource" +) + +// CybStrng contains all strings. +var CybStrng = I18nFile([resource.LanguageCount]string{"cybstrng.res", "frnstrng.res", "gerstrng.res"}) + +// MfdArt contains all MFD graphics. +var MfdArt = I18nFile([resource.LanguageCount]string{"mfdart.res", "mfdfrn.res", "mfdger.res"}) + +// CitALog contains all log audio. +var CitALog = I18nFile([resource.LanguageCount]string{"citalog.res", "frnalog.res", "geralog.res"}) + +// CitBark contains all bark audio. +var CitBark = I18nFile([resource.LanguageCount]string{"citbark.res", "frnbark.res", "gerbark.res"}) + +// LowIntr contains the low-res intro video. +var LowIntr = I18nFile([resource.LanguageCount]string{"lowintr.res", "lofrintr.res", "logeintr.res"}) + +// SvgaIntr contains the high-res intro video. +var SvgaIntr = I18nFile([resource.LanguageCount]string{"svgaintr.res", "svfrintr.res", "svgeintr.res"}) + +// Archive contains the game world. +var Archive = AnyLanguage("archive.dat") + +// GamePal contains the game palettes. +var GamePal = AnyLanguage("gamepal.res") + +// GameScr contains big bitmaps. +var GameScr = AnyLanguage("gamescr.res") + +// Texture contains all textures. +var Texture = AnyLanguage("texture.res") + +// VidMail contains all video mails. +var VidMail = AnyLanguage("vidmail.res") + +// Death contains another death video. +var Death = AnyLanguage("death.res") + +// Intro contains another intro video. +var Intro = AnyLanguage("intro.res") + +// Start1 contains ... ?. +var Start1 = AnyLanguage("start1.res") + +// Win1 contains ... ?. +var Win1 = AnyLanguage("win1.res") + +// LowDeth contains the low-res death video. +var LowDeth = AnyLanguage("lowdeth.res") + +// LowEnd contains the low-res end video. +var LowEnd = AnyLanguage("lowend.res") + +// SvgaDeth contains the high-res death video. +var SvgaDeth = AnyLanguage("svgadeth.res") + +// SvgaEnd contains the high-res end video. +var SvgaEnd = AnyLanguage("svgaend.res") + +// Obj3D contains 3D objects. +var Obj3D = AnyLanguage("obj3D.res") + +// ObjArt contains object art. +var ObjArt = AnyLanguage("objart.res") + +// ObjArt2 contains further object art. +var ObjArt2 = AnyLanguage("objart2.res") + +// ObjArt3 contains further object art. +var ObjArt3 = AnyLanguage("objart3.res") + +// CitMat contains materials for 3D objects. +var CitMat = AnyLanguage("citmat.res") + +// CutsPal contains palettes for the cutscenes. +var CutsPal = AnyLanguage("cutspal.res") + +// HandArt contains bitmaps for grabbed things. +var HandArt = AnyLanguage("handart.res") + +// SideArt contains bitmaps for the side buttons. +var SideArt = AnyLanguage("sideart.res") + +// DigiFX contains all the effect sounds. +var DigiFX = AnyLanguage("digifx.res") + +// Splash contains the splash screens. +var Splash = AnyLanguage("splash.res") + +// SplshPal contains the splash screen palettes. +var SplshPal = AnyLanguage("splspal.res") + +// LowResVideos returns the filename descriptors of all low-res videos. +func LowResVideos() FilenameList { + return []Filename{LowIntr, LowDeth, LowEnd, Intro, Start1, Win1} +} + +// HighResVideos returns the filename descriptors of all high-res videos. +func HighResVideos() FilenameList { + return []Filename{SvgaIntr, SvgaDeth, SvgaEnd} +} + +// LocalizedFiles returns the filename descriptors of all files that are localized. +func LocalizedFiles() []Filename { + return []Filename{CybStrng, MfdArt, CitALog, CitBark, LowIntr, SvgaIntr} +} + +// LocalizeFilename returns the language that the resource file would typically contain. +func LocalizeFilename(filename string) resource.Language { + all := LocalizedFiles() + lowercase := strings.ToLower(filename) + result := resource.LangAny + for _, lang := range resource.Languages() { + for _, file := range all { + if file.For(lang) == lowercase { + result = lang + } + } + } + return result +} diff --git a/modding/hacked/ss1/world/ids/Filenames_test.go b/modding/hacked/ss1/world/ids/Filenames_test.go new file mode 100644 index 0000000..900f8ee --- /dev/null +++ b/modding/hacked/ss1/world/ids/Filenames_test.go @@ -0,0 +1,73 @@ +package ids_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ss1/resource" + "github.com/inkyblackness/hacked/ss1/world/ids" + + "github.com/stretchr/testify/assert" +) + +func TestLocalizedResources(t *testing.T) { + tt := []struct { + filename string + expected resource.Language + }{ + {"citmat.res", resource.LangAny}, + {"digifx.res", resource.LangAny}, + {"gamescr.res", resource.LangAny}, + {"handart.res", resource.LangAny}, + {"intro.res", resource.LangAny}, + {"obj3d.res", resource.LangAny}, + {"objart2.res", resource.LangAny}, + {"objart3.res", resource.LangAny}, + {"sideart.res", resource.LangAny}, + {"texture.res", resource.LangAny}, + + {"cybstrng.res", resource.LangDefault}, + {"mfdart.res", resource.LangDefault}, + + {"frnstrng.res", resource.LangFrench}, + {"mfdfrn.res", resource.LangFrench}, + + {"gerstrng.res", resource.LangGerman}, + {"mfdger.res", resource.LangGerman}, + + {"archive.dat", resource.LangAny}, + {"cutspal.res", resource.LangAny}, + {"death.res", resource.LangAny}, + {"gamepal.res", resource.LangAny}, + {"intro.res", resource.LangAny}, + {"lowdeth.res", resource.LangAny}, + {"lowend.res", resource.LangAny}, + {"objart.res", resource.LangAny}, + {"splash.res", resource.LangAny}, + {"splshpal.res", resource.LangAny}, + {"start1.res", resource.LangAny}, + {"svgadeth.res", resource.LangAny}, + {"svgaend.res", resource.LangAny}, + {"vidmail.res", resource.LangAny}, + {"win1.res", resource.LangAny}, + + {"citalog.res", resource.LangDefault}, + {"citbark.res", resource.LangDefault}, + {"lowintr.res", resource.LangDefault}, + {"svgaintr.res", resource.LangDefault}, + + {"frnalog.res", resource.LangFrench}, + {"frnbark.res", resource.LangFrench}, + {"lofrintr.res", resource.LangFrench}, + {"svfrintr.res", resource.LangFrench}, + + {"geralog.res", resource.LangGerman}, + {"gerbark.res", resource.LangGerman}, + {"logeintr.res", resource.LangGerman}, + {"svgeintr.res", resource.LangGerman}, + } + + for _, tc := range tt { + result := ids.LocalizeFilename(tc.filename) + assert.Equal(t, tc.expected, result, "Wrong language for <"+tc.filename+">") + } +} diff --git a/modding/hacked/ss1/world/ids/Identifier.go b/modding/hacked/ss1/world/ids/Identifier.go new file mode 100644 index 0000000..6924150 --- /dev/null +++ b/modding/hacked/ss1/world/ids/Identifier.go @@ -0,0 +1,88 @@ +package ids + +import "github.com/inkyblackness/hacked/ss1/resource" + +// Palette identifier are listed below. +const ( + GamePalettesStart resource.ID = 0x02BC +) + +// Texture identifier are listed below. +const ( + IconTextures resource.ID = 0x004C + SmallTextures resource.ID = 0x004D + MediumTextures resource.ID = 0x02C3 + LargeTextures resource.ID = 0x03E8 + + ScreenTextures resource.ID = 0x0141 + + TextureNames resource.ID = 0x086A + TextureUsages resource.ID = 0x086B +) + +// Bitmap identifier are listed below. +const ( + ObjectBitmaps resource.ID = 0x0546 + ObjectTextureBitmaps resource.ID = 0x01DB + ObjectMaterialBitmaps resource.ID = 0x0884 + + IconBitmaps resource.ID = 0x004E + GraffitiBitmaps resource.ID = 0x004F + + MfdDataBitmaps resource.ID = 0x0028 +) + +// Animation and video identifier are listed below. +const ( + VideoMailBitmapsStart resource.ID = 0x0A40 + VideoMailAnimationsStart resource.ID = 0x0A4C +) + +// Movie identifier are listed below. +const ( + MovieIntro resource.ID = 0x0BD6 + MovieDeath resource.ID = 0x0BD7 + MovieEnd resource.ID = 0x0BD8 +) + +// Text identifier are listed below. +const ( + PaperTextsStart resource.ID = 0x003C + TrapMessageTexts resource.ID = 0x0867 + WordTexts resource.ID = 0x0868 + PanelNameTexts resource.ID = 0x0869 + LogCategoryTexts resource.ID = 0x0870 + VariousMessageTexts resource.ID = 0x0871 + ScreenMessageTexts resource.ID = 0x0877 + InfoNodeMessageTexts resource.ID = 0x0878 + AccessCardNameTexts resource.ID = 0x0879 + DataletMessageTexts resource.ID = 0x087A + + ObjectLongNames resource.ID = 0x0024 + ObjectShortNames resource.ID = 0x086D +) + +// Message identifier are listed below. +const ( + MailsStart resource.ID = 0x0989 + LogsStart resource.ID = 0x09B8 + FragmentsStart resource.ID = 0x0A98 + + MailsAudioStart resource.ID = 0x0989 + 300 + LogsAudioStart resource.ID = 0x09B8 + 300 +) + +// Sound identifier are listed below. +const ( + TrapMessagesAudioStart resource.ID = 0x0C1C + + SoundEffectsAudioStart resource.ID = 0x00C9 +) + +// Archive identifier are listed below. +const ( + ArchiveName resource.ID = 0x0FA0 + GameState resource.ID = 0x0FA1 + + LevelResourcesStart resource.ID = 4000 +) diff --git a/modding/hacked/ss1/world/ids/Info.go b/modding/hacked/ss1/world/ids/Info.go new file mode 100644 index 0000000..349f948 --- /dev/null +++ b/modding/hacked/ss1/world/ids/Info.go @@ -0,0 +1,161 @@ +package ids + +import ( + "github.com/inkyblackness/hacked/ss1/content/archive" + "github.com/inkyblackness/hacked/ss1/content/archive/level/lvlids" + "github.com/inkyblackness/hacked/ss1/content/object" + "github.com/inkyblackness/hacked/ss1/resource" +) + +// ResourceInfo describes a group of resources with their default serialization properties. +type ResourceInfo struct { + // StartID is the first ID of the resource block (inclusive). + StartID resource.ID + // EndID is the last ID of the resource block (exclusive). + EndID resource.ID + + // ContentType describes how to interpret resource data. + ContentType resource.ContentType + // Compound indicates whether the resource has a variable amount of blocks. Simple resources always have one block. + Compound bool + // Compressed indicates that the resource data shall be stored in compressed form. + Compressed bool + + // List is set for compound resources that have an atomic resource per block. + List bool + // MaxCount describes how many resources can be stored at maximum. Zero for unlimited / defined somewhere else. + MaxCount int + + // ResFile specifies the .res file in which this resource should be stored. + ResFile Filename +} + +// Info returns the resource information for the identified resource. +func Info(id resource.ID) (ResourceInfo, bool) { + info, existing := infoByID[id] + return info, existing +} + +func init() { + register := func(info ResourceInfo) { + count := info.EndID.Value() - info.StartID.Value() + for offset := 0; offset < int(count); offset++ { + infoByID[info.StartID.Plus(offset)] = info + } + } + for _, info := range infoList { + register(info) + } + levelInfo := func(lvl int, lvlResID int, compressed bool) ResourceInfo { + resID := LevelResourcesStart.Plus(lvl*lvlids.PerLevel + lvlResID) + return ResourceInfo{ + StartID: resID, + EndID: resID.Plus(1), + + ContentType: resource.Archive, + Compound: false, + Compressed: compressed, + + List: false, + MaxCount: 1, + + ResFile: Archive, + } + } + // In the following, the compression flags were set if the vanilla archive had + // the respective resource compressed in at least one level. + // Most of them were compressed uniform across all levels, some had exceptions. + for lvl := 0; lvl < archive.MaxLevels; lvl++ { + register(levelInfo(lvl, lvlids.MapVersionNumber, false)) + register(levelInfo(lvl, lvlids.ObjectVersionNumber, false)) + register(levelInfo(lvl, lvlids.Information, true)) + + register(levelInfo(lvl, lvlids.TileMap, true)) + register(levelInfo(lvl, lvlids.Schedules, false)) + register(levelInfo(lvl, lvlids.TextureAtlas, false)) + register(levelInfo(lvl, lvlids.ObjectMainTable, true)) + register(levelInfo(lvl, lvlids.ObjectCrossRefTable, true)) + for class := 0; class < object.ClassCount; class++ { + register(levelInfo(lvl, lvlids.ObjectClassTablesStart+class, true)) + register(levelInfo(lvl, lvlids.ObjectDefaultTablesStart+class, true)) + } + + register(levelInfo(lvl, lvlids.SavefileVersion, false)) + register(levelInfo(lvl, lvlids.Unused41, false)) + + register(levelInfo(lvl, lvlids.TextureAnimations, true)) + register(levelInfo(lvl, lvlids.SurveillanceSources, true)) + register(levelInfo(lvl, lvlids.SurveillanceSurrogates, true)) + register(levelInfo(lvl, lvlids.Parameters, true)) + register(levelInfo(lvl, lvlids.MapNotes, true)) + register(levelInfo(lvl, lvlids.MapNotesPointer, false)) + + register(levelInfo(lvl, lvlids.Unknown48, true)) + register(levelInfo(lvl, lvlids.Unknown49, true)) + register(levelInfo(lvl, lvlids.Unknown50, false)) + + register(levelInfo(lvl, lvlids.LoopConfiguration, true)) + + register(levelInfo(lvl, lvlids.Unknown52, false)) + register(levelInfo(lvl, lvlids.HeightSemaphores, true)) + } +} + +var infoByID = map[resource.ID]ResourceInfo{} + +var infoList = []ResourceInfo{ + {GamePalettesStart, GamePalettesStart.Plus(3), resource.Palette, false, false, false, 3, GamePal}, + + {IconTextures, IconTextures.Plus(1), resource.Bitmap, true, false, true, 293, Texture}, + {SmallTextures, SmallTextures.Plus(1), resource.Bitmap, true, false, true, 293, Texture}, + {MediumTextures, MediumTextures.Plus(293), resource.Bitmap, true, false, false, 293, Texture}, + {LargeTextures, LargeTextures.Plus(293), resource.Bitmap, true, false, false, 293, Texture}, + {TextureNames, TextureNames.Plus(1), resource.Text, true, false, true, 293, CybStrng}, + {TextureUsages, TextureUsages.Plus(1), resource.Text, true, false, true, 293, CybStrng}, + + {ObjectBitmaps, ObjectBitmaps.Plus(1), resource.Bitmap, true, false, true, 0, ObjArt}, + {ObjectTextureBitmaps, ObjectTextureBitmaps.Plus(64), resource.Bitmap, true, false, false, 64, CitMat}, + {ObjectMaterialBitmaps, ObjectMaterialBitmaps.Plus(32), resource.Bitmap, true, false, false, 32, CitMat}, + + {ScreenTextures, ScreenTextures.Plus(102), resource.Bitmap, true, false, false, 102, Texture}, + + {IconBitmaps, IconBitmaps.Plus(1), resource.Bitmap, true, false, true, 64, ObjArt3}, + {GraffitiBitmaps, GraffitiBitmaps.Plus(1), resource.Bitmap, true, false, true, 64, ObjArt3}, + + {MfdDataBitmaps, MfdDataBitmaps.Plus(1), resource.Bitmap, true, true, true, 256, MfdArt}, + + {VideoMailBitmapsStart, VideoMailBitmapsStart.Plus(12), resource.Bitmap, true, false, false, 12, VidMail}, + {VideoMailAnimationsStart, VideoMailAnimationsStart.Plus(12), resource.Animation, true, false, false, 12, VidMail}, + + {MovieIntro, MovieIntro.Plus(1), resource.Movie, false, false, false, 1, SvgaIntr}, + {MovieDeath, MovieDeath.Plus(1), resource.Movie, false, false, false, 1, SvgaDeth}, + {MovieEnd, MovieEnd.Plus(1), resource.Movie, false, false, false, 1, SvgaEnd}, + + {PaperTextsStart, PaperTextsStart.Plus(16), resource.Text, true, false, false, 16, CybStrng}, + + {TrapMessageTexts, TrapMessageTexts.Plus(1), resource.Text, true, false, true, 256, CybStrng}, + {TrapMessagesAudioStart, TrapMessagesAudioStart.Plus(256), resource.Movie, false, false, false, 256, CitBark}, + + {SoundEffectsAudioStart, SoundEffectsAudioStart.Plus(114), resource.Sound, false, false, false, 114, DigiFX}, + + {WordTexts, WordTexts.Plus(1), resource.Text, true, false, true, 512, CybStrng}, + {PanelNameTexts, PanelNameTexts.Plus(1), resource.Text, true, false, true, 256, CybStrng}, + {LogCategoryTexts, LogCategoryTexts.Plus(1), resource.Text, true, false, true, 16, CybStrng}, + {VariousMessageTexts, VariousMessageTexts.Plus(1), resource.Text, true, false, true, 256, CybStrng}, + {ScreenMessageTexts, ScreenMessageTexts.Plus(1), resource.Text, true, false, true, 120, CybStrng}, + {InfoNodeMessageTexts, InfoNodeMessageTexts.Plus(1), resource.Text, true, false, true, 256, CybStrng}, + {AccessCardNameTexts, AccessCardNameTexts.Plus(1), resource.Text, true, false, true, 32 * 2, CybStrng}, + {DataletMessageTexts, DataletMessageTexts.Plus(1), resource.Text, true, false, true, 256, CybStrng}, + + {MailsStart, MailsStart.Plus(47), resource.Text, true, false, false, 47, CybStrng}, + {LogsStart, LogsStart.Plus(136), resource.Text, true, false, false, 136, CybStrng}, + {FragmentsStart, FragmentsStart.Plus(16), resource.Text, true, false, false, 16, CybStrng}, + {MailsAudioStart, MailsAudioStart.Plus(47), resource.Movie, false, false, false, 47, CitALog}, + {LogsAudioStart, LogsAudioStart.Plus(224), resource.Movie, false, false, false, 224, CitALog}, + + {ObjectLongNames, ObjectLongNames.Plus(1), resource.Text, true, false, true, 0, CybStrng}, + {ObjectShortNames, ObjectShortNames.Plus(1), resource.Text, true, false, true, 0, CybStrng}, + + {ArchiveName, ArchiveName.Plus(1), resource.Archive, false, false, false, 1, Archive}, + {GameState, GameState.Plus(1), resource.Archive, false, true, false, 1, Archive}, +} diff --git a/modding/hacked/ss1/world/ids/Sounds.go b/modding/hacked/ss1/world/ids/Sounds.go new file mode 100644 index 0000000..5bb2cef --- /dev/null +++ b/modding/hacked/ss1/world/ids/Sounds.go @@ -0,0 +1,197 @@ +package ids + +// SoundEffectInfo describes one sound effect in the game. +type SoundEffectInfo struct { + // Name is the unique identifier for the effect source. + Name string + // Index refers to the audio index. Multiple effects may use the same audio. -1 indicates no audio mapped. + AudioIndex int +} + +// SoundEffectsForAudio returns the effect information that share the same audio. +func SoundEffectsForAudio(index int) []SoundEffectInfo { + var result []SoundEffectInfo + for _, effect := range soundEffects { + if effect.AudioIndex == index { + result = append(result, effect) + } + } + return result +} + +// The following table is based on the constants found in sfxlist.h (non-demo case). +var soundEffects = []SoundEffectInfo{ + // Doors + {Name: "DoorMetal", AudioIndex: 3}, + {Name: "DoorNormal", AudioIndex: 5}, + {Name: "DoorIris", AudioIndex: 6}, + {Name: "DoorBulkhead", AudioIndex: 67}, + {Name: "DoorGrating", AudioIndex: 90}, + + // Ambient + {Name: "Bridge1", AudioIndex: -1}, + {Name: "Bridge2", AudioIndex: -1}, + {Name: "Bridge3", AudioIndex: -1}, + {Name: "Bridge4", AudioIndex: -1}, + {Name: "Maint1", AudioIndex: 9}, + {Name: "Maint2", AudioIndex: -1}, + {Name: "Maint3", AudioIndex: -1}, + {Name: "Maint4", AudioIndex: -1}, + {Name: "Grove1", AudioIndex: 43}, + + // Critters + {Name: "Death1", AudioIndex: 11}, + {Name: "Death2", AudioIndex: 49}, + {Name: "Death3", AudioIndex: 50}, + {Name: "Death4", AudioIndex: 51}, + {Name: "Death5", AudioIndex: 53}, + {Name: "Death6", AudioIndex: 54}, + {Name: "Death7", AudioIndex: 68}, + {Name: "Death8", AudioIndex: 69}, + {Name: "Death9", AudioIndex: 88}, + {Name: "Death10", AudioIndex: 93}, + {Name: "Death11", AudioIndex: 101}, + {Name: "Attack1", AudioIndex: 12}, + {Name: "Attack4", AudioIndex: 46}, + {Name: "Attack5", AudioIndex: 48}, + {Name: "Attack6", AudioIndex: 52}, + {Name: "Attack7", AudioIndex: 55}, + {Name: "Attack8", AudioIndex: 63}, + {Name: "Attack9", AudioIndex: 16}, + {Name: "Notice1", AudioIndex: 58}, + {Name: "Notice2", AudioIndex: 59}, + {Name: "Notice3", AudioIndex: 74}, + {Name: "Notice4", AudioIndex: 75}, + {Name: "Notice5", AudioIndex: 100}, + {Name: "Near1", AudioIndex: 73}, + {Name: "Near2", AudioIndex: 56}, + {Name: "Near3", AudioIndex: 47}, + {Name: "Near4", AudioIndex: 25}, + + // Wacky Objects + {Name: "Repulsor", AudioIndex: -1}, + {Name: "ForceBridge", AudioIndex: 72}, + {Name: "TerrainElevLoop", AudioIndex: 15}, + {Name: "SparkingCable", AudioIndex: 87}, + {Name: "SurgeryMachine", AudioIndex: 102}, + + // Combat + {Name: "GunMinipistol", AudioIndex: 39}, + {Name: "GunDartpistol", AudioIndex: 86}, + {Name: "GunMagnum", AudioIndex: 40}, + {Name: "GunAssault", AudioIndex: 17}, + {Name: "GunRiot", AudioIndex: 41}, + {Name: "GunFlechette", AudioIndex: 38}, + {Name: "GunSkorpion", AudioIndex: 65}, + {Name: "GunMagpulse", AudioIndex: 45}, + {Name: "GunRailgun", AudioIndex: 29}, + {Name: "GunPipeHitMeat", AudioIndex: 4}, + {Name: "GunPipeHitMetal", AudioIndex: 21}, + {Name: "GunPipeMiss", AudioIndex: 24}, + {Name: "GunLaserepeeHit", AudioIndex: 31}, + {Name: "GunLaserepeeMiss", AudioIndex: 34}, + {Name: "GunPhaser", AudioIndex: 18}, + {Name: "GunBlaster", AudioIndex: 94}, + {Name: "GunIonbeam", AudioIndex: 95}, + {Name: "GunStungun", AudioIndex: 19}, + {Name: "GunPlasma", AudioIndex: 97}, + + {Name: "PlayerHurt", AudioIndex: 64}, + {Name: "Shield1", AudioIndex: 32}, + {Name: "Shield2", AudioIndex: 20}, + {Name: "ShieldUp", AudioIndex: 96}, + {Name: "ShieldDown", AudioIndex: 42}, + {Name: "MetalSpang", AudioIndex: 89}, + {Name: "Radiation", AudioIndex: 2}, + + {Name: "Reload1", AudioIndex: 22}, + {Name: "Reload2", AudioIndex: 23}, + {Name: "GrenadeArm", AudioIndex: 7}, + {Name: "BatteryUse", AudioIndex: 28}, + + {Name: "Explosion1", AudioIndex: 44}, + {Name: "Rumble", AudioIndex: 106}, + {Name: "Teleport", AudioIndex: 103}, + + {Name: "MonitorExplode", AudioIndex: 57}, + {Name: "CameraExplode", AudioIndex: 8}, + {Name: "CpuExplode", AudioIndex: 10}, + {Name: "DestroyCrate", AudioIndex: 37}, + {Name: "DestroyBarrel", AudioIndex: 109}, + + // Cspace + {Name: "Pulser", AudioIndex: -1}, + {Name: "Drill", AudioIndex: -1}, + {Name: "Disc", AudioIndex: -1}, + {Name: "Datastorm", AudioIndex: -1}, + + {Name: "Recall", AudioIndex: -1}, + {Name: "Turbo", AudioIndex: -1}, + {Name: "Fakeid", AudioIndex: -1}, + {Name: "Decoy", AudioIndex: -1}, + + {Name: "EnterCspace", AudioIndex: 27}, + {Name: "OttoShodan", AudioIndex: 30}, + {Name: "CyberDamage", AudioIndex: -1}, + {Name: "Cyberheal", AudioIndex: -1}, + {Name: "Cybertoggle", AudioIndex: -1}, + {Name: "IceDefense", AudioIndex: -1}, + + {Name: "CyberAttack1", AudioIndex: -1}, + {Name: "CyberAttack2", AudioIndex: -1}, + {Name: "CyberAttack3", AudioIndex: -1}, + + // MFD and UI Wackiness + {Name: "VideoDown", AudioIndex: 33}, + {Name: "MfdButton", AudioIndex: 35}, + {Name: "InventButton", AudioIndex: 79}, + {Name: "InventSelect", AudioIndex: 80}, + {Name: "InventAdd", AudioIndex: 81}, + {Name: "InventWare", AudioIndex: 82}, + {Name: "PatchUse", AudioIndex: 83}, + {Name: "ZoomBox", AudioIndex: 84}, + {Name: "MapZoom", AudioIndex: 85}, + {Name: "MfdKeypad", AudioIndex: 76}, + {Name: "MfdBuzz", AudioIndex: 77}, + {Name: "MfdSuccess", AudioIndex: 78}, + {Name: "Goggle", AudioIndex: 0}, + {Name: "Hudfrob", AudioIndex: 1}, + {Name: "Static", AudioIndex: 2}, + {Name: "Email", AudioIndex: 107}, + + // SHODAN + {Name: "ShodanBark", AudioIndex: 30}, + {Name: "ShodanWeak", AudioIndex: 30}, + {Name: "ShodanStrong", AudioIndex: 30}, + + // Other + {Name: "PanelSuccess", AudioIndex: 78}, + {Name: "PowerOut", AudioIndex: 26}, + {Name: "EnergyDrain", AudioIndex: 14}, + {Name: "EnergyRecharge", AudioIndex: 98}, + {Name: "Surge", AudioIndex: 60}, + {Name: "Vmail", AudioIndex: 92}, + {Name: "DropItem", AudioIndex: 99}, + + // 3d World + {Name: "Button", AudioIndex: 71}, + {Name: "MechButton", AudioIndex: 36}, + {Name: "Bigbutton", AudioIndex: 61}, + {Name: "NormalLever", AudioIndex: 62}, + {Name: "Biglever", AudioIndex: 62}, + {Name: "Klaxon", AudioIndex: 70}, + + // Plot + {Name: "GroveJett", AudioIndex: 66}, + + // Extra + {Name: "PanelConfirm", AudioIndex: 13}, + {Name: "Death12", AudioIndex: 104}, + {Name: "Death13", AudioIndex: 105}, + {Name: "Death14", AudioIndex: 108}, + {Name: "Attack10", AudioIndex: 91}, + {Name: "Attack11", AudioIndex: 110}, + {Name: "Near5", AudioIndex: 111}, + {Name: "Death15", AudioIndex: 112}, + {Name: "FunPackAttackingUs", AudioIndex: 113}, +} diff --git a/modding/hacked/ss1/world/ids/doc.go b/modding/hacked/ss1/world/ids/doc.go new file mode 100644 index 0000000..848dc6b --- /dev/null +++ b/modding/hacked/ss1/world/ids/doc.go @@ -0,0 +1,3 @@ +// Package ids contains constants for all necessary identifier used in the world. +// This package contains resource identifier, filenames and other constants necessary for storage. +package ids diff --git a/modding/hacked/ui/gui/Context.go b/modding/hacked/ui/gui/Context.go new file mode 100644 index 0000000..ea6252f --- /dev/null +++ b/modding/hacked/ui/gui/Context.go @@ -0,0 +1,420 @@ +package gui + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/inkyblackness/imgui-go/v3" + + "github.com/inkyblackness/hacked/ui/opengl" +) + +type fontFileError struct { + Filename string +} + +func (err fontFileError) Error() string { + return fmt.Sprintf("could not load font '%s'", err.Filename) +} + +// ContextParameters describes how to create the context. +type ContextParameters struct { + // ConfigDir is used to store the UI state in. + ConfigDir string + // FontFile is the filename of a .TTF file to load instead of using default. + FontFile string + // FontSize is the requested size of the font. Defaults to 12. + FontSize float32 +} + +// BitmapTextureQuery resolves the texture and the palette to be used for a bitmap. +type BitmapTextureQuery func(id imgui.TextureID) (palette uint32, texture uint32) + +// Context describes a scope for a graphical user interface. +// It is based on ImGui. +type Context struct { + imguiContext *imgui.Context + window opengl.Window + + lastRenderTime time.Time + + mouseButtonWasDown [3]bool + mouseButtonIsDown [3]bool + + fontTexture uint32 + shaderHandle uint32 + attribLocationType int32 + attribLocationTex int32 + attribLocationPal int32 + attribLocationProjMtx int32 + attribLocationPosition int32 + attribLocationUV int32 + attribLocationColor int32 + vboHandle uint32 + elementsHandle uint32 +} + +// NewContext initializes a new UI context based on the provided OpenGL window. +func NewContext(window opengl.Window, param ContextParameters) (context *Context, err error) { + imgui.SetAssertHandler(nil) + context = &Context{ + imguiContext: imgui.CreateContext(nil), + window: window, + } + + imgui.CurrentIO().SetIniFilename(filepath.Join(param.ConfigDir, "imgui.ini")) + + err = context.createDeviceObjects(param) + if err != nil { + context.Destroy() + context = nil + } + + return +} + +// Destroy cleans up the resources of the graphical user interface. +func (context *Context) Destroy() { + context.destroyDeviceObjects(context.window.OpenGL()) + context.imguiContext.Destroy() +} + +// MouseButtonChanged is called when the state of a button has changed. +func (context *Context) MouseButtonChanged(buttonIndex int, down bool) { + if (buttonIndex >= 0) && (buttonIndex < len(context.mouseButtonIsDown)) { + context.mouseButtonIsDown[buttonIndex] = down + if down { + context.mouseButtonWasDown[buttonIndex] = down + } + } +} + +// IsUsingMouse returns true if the UI is using the mouse. +// The application should not process mouse events in this case. +func (context Context) IsUsingMouse() bool { + return imgui.CurrentIO().WantCaptureMouse() +} + +// IsUsingKeyboard returns true if the UI is currently capturing keyboard input. +// The application should not process keyboard input events in this case. +func (context Context) IsUsingKeyboard() bool { + return imgui.CurrentIO().WantTextInput() +} + +// NewFrame must be called at the start of rendering. +func (context *Context) NewFrame() { + io := imgui.CurrentIO() + + windowWidth, windowHeight := context.window.Size() + io.SetDisplaySize(imgui.Vec2{X: float32(windowWidth), Y: float32(windowHeight)}) + + now := time.Now() + if !context.lastRenderTime.IsZero() { + elapsed := now.Sub(context.lastRenderTime) + io.SetDeltaTime(float32(elapsed.Seconds())) + } + context.lastRenderTime = now + + for i := 0; i < len(context.mouseButtonWasDown); i++ { + down := context.mouseButtonWasDown[i] || context.mouseButtonIsDown[i] + io.SetMouseButtonDown(i, down) + context.mouseButtonWasDown[i] = false + } + + imgui.NewFrame() +} + +// Render must be called at the end of rendering. +func (context *Context) Render(bitmapTextureQuery BitmapTextureQuery) { + imgui.Render() + context.renderDrawData(imgui.RenderedDrawData(), bitmapTextureQuery) +} + +func (context *Context) createDeviceObjects(param ContextParameters) (err error) { + gl := context.window.OpenGL() + glslVersion := "#version 150" + + vertexShaderSource := glslVersion + ` +uniform mat4 ProjMtx; +in vec2 Position; +in vec2 UV; +in vec4 Color; +out vec2 Frag_UV; +out vec4 Frag_Color; +void main() +{ + Frag_UV = UV; + Frag_Color = Color; + gl_Position = ProjMtx * vec4(Position.xy,0,1); +} +` + fragmentShaderSource := glslVersion + ` +uniform int ImageType; +uniform sampler2D Texture; +uniform sampler2D Palette; +in vec2 Frag_UV; +in vec4 Frag_Color; +out vec4 Out_Color; +void main() +{ + if (ImageType == 1) + { + vec4 pixel = texture(Texture, Frag_UV.st); + Out_Color = Frag_Color * texture(Palette, vec2(pixel.r, 0.5)); + } + else if (ImageType == 2) + { + Out_Color = texture(Texture, Frag_UV.st) * Frag_Color; + } + else + { + Out_Color = vec4(Frag_Color.rgb, Frag_Color.a * texture( Texture, Frag_UV.st).r); + } +} +` + context.shaderHandle, err = opengl.LinkNewStandardProgram(gl, vertexShaderSource, fragmentShaderSource) + if err != nil { + return + } + + context.attribLocationType = gl.GetUniformLocation(context.shaderHandle, "ImageType") + context.attribLocationTex = gl.GetUniformLocation(context.shaderHandle, "Texture") + context.attribLocationPal = gl.GetUniformLocation(context.shaderHandle, "Palette") + context.attribLocationProjMtx = gl.GetUniformLocation(context.shaderHandle, "ProjMtx") + context.attribLocationPosition = gl.GetAttribLocation(context.shaderHandle, "Position") + context.attribLocationUV = gl.GetAttribLocation(context.shaderHandle, "UV") + context.attribLocationColor = gl.GetAttribLocation(context.shaderHandle, "Color") + + buffers := gl.GenBuffers(2) + context.vboHandle = buffers[0] + context.elementsHandle = buffers[1] + + return context.createFontsTexture(gl, param) +} + +func (context *Context) createFontsTexture(gl opengl.OpenGL, param ContextParameters) error { + io := imgui.CurrentIO() + fontAtlas := io.Fonts() + if len(param.FontFile) > 0 { + fontSize := float32(16.0) + if param.FontSize > 0.0 { + fontSize = param.FontSize + } + font := fontAtlas.AddFontFromFileTTF(param.FontFile, fontSize) + if font == imgui.DefaultFont { + return fontFileError{Filename: param.FontFile} + } + } + image := fontAtlas.TextureDataAlpha8() + + context.fontTexture = gl.GenTextures(1)[0] + gl.BindTexture(opengl.TEXTURE_2D, context.fontTexture) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MIN_FILTER, opengl.LINEAR) + gl.TexParameteri(opengl.TEXTURE_2D, opengl.TEXTURE_MAG_FILTER, opengl.LINEAR) + gl.PixelStorei(opengl.UNPACK_ROW_LENGTH, 0) + gl.TexImage2D(opengl.TEXTURE_2D, 0, opengl.RED, int32(image.Width), int32(image.Height), + 0, opengl.RED, opengl.UNSIGNED_BYTE, image.Pixels) + + io.Fonts().SetTextureID(TextureIDForSimpleTexture(context.fontTexture)) + + gl.BindTexture(opengl.TEXTURE_2D, 0) + return nil +} + +func (context *Context) destroyDeviceObjects(gl opengl.OpenGL) { + if context.vboHandle != 0 { + gl.DeleteBuffers([]uint32{context.vboHandle}) + } + context.vboHandle = 0 + if context.elementsHandle != 0 { + gl.DeleteBuffers([]uint32{context.elementsHandle}) + } + context.elementsHandle = 0 + + if context.shaderHandle != 0 { + gl.DeleteProgram(context.shaderHandle) + } + context.shaderHandle = 0 + + if context.fontTexture != 0 { + gl.DeleteTextures([]uint32{context.fontTexture}) + imgui.CurrentIO().Fonts().SetTextureID(0) + context.fontTexture = 0 + } +} + +func (context *Context) renderDrawData(drawData imgui.DrawData, bitmapTextureQuery BitmapTextureQuery) { + gl := context.window.OpenGL() + + displayWidth, displayHeight := context.window.Size() + + // Backup GL state + var lastActiveTexture int32 + gl.GetIntegerv(opengl.ACTIVE_TEXTURE, &lastActiveTexture) + gl.ActiveTexture(opengl.TEXTURE0) + var lastProgram int32 + gl.GetIntegerv(opengl.CURRENT_PROGRAM, &lastProgram) + var lastTexture int32 + gl.GetIntegerv(opengl.TEXTURE_BINDING_2D, &lastTexture) + var lastSampler int32 + gl.GetIntegerv(opengl.SAMPLER_BINDING, &lastSampler) + var lastArrayBuffer int32 + gl.GetIntegerv(opengl.ARRAY_BUFFER_BINDING, &lastArrayBuffer) + var lastElementArrayBuffer int32 + gl.GetIntegerv(opengl.ELEMENT_ARRAY_BUFFER_BINDING, &lastElementArrayBuffer) + var lastVertexArray int32 + gl.GetIntegerv(opengl.VERTEX_ARRAY_BINDING, &lastVertexArray) + var lastPolygonMode [2]int32 + gl.GetIntegerv(opengl.POLYGON_MODE, &lastPolygonMode[0]) + var lastViewport [4]int32 + gl.GetIntegerv(opengl.VIEWPORT, &lastViewport[0]) + var lastScissorBox [4]int32 + gl.GetIntegerv(opengl.SCISSOR_BOX, &lastScissorBox[0]) + var lastBlendSrcRgb int32 + gl.GetIntegerv(opengl.BLEND_SRC_RGB, &lastBlendSrcRgb) + var lastBlendDstRgb int32 + gl.GetIntegerv(opengl.BLEND_DST_RGB, &lastBlendDstRgb) + var lastBlendSrcAlpha int32 + gl.GetIntegerv(opengl.BLEND_SRC_ALPHA, &lastBlendSrcAlpha) + var lastBlendDstAlpha int32 + gl.GetIntegerv(opengl.BLEND_DST_ALPHA, &lastBlendDstAlpha) + var lastBlendEquationRgb int32 + gl.GetIntegerv(opengl.BLEND_EQUATION_RGB, &lastBlendEquationRgb) + var lastBlendEquationAlpha int32 + gl.GetIntegerv(opengl.BLEND_EQUATION_ALPHA, &lastBlendEquationAlpha) + lastEnableBlend := gl.IsEnabled(opengl.BLEND) + lastEnableCullFace := gl.IsEnabled(opengl.CULL_FACE) + lastEnableDepthTest := gl.IsEnabled(opengl.DEPTH_TEST) + lastEnableScissorTest := gl.IsEnabled(opengl.SCISSOR_TEST) + + // Setup render state: alpha-blending enabled, no face culling, no depth testing, scissor enabled, polygon fill + gl.Enable(opengl.BLEND) + gl.BlendEquation(opengl.FUNC_ADD) + gl.BlendFunc(opengl.SRC_ALPHA, opengl.ONE_MINUS_SRC_ALPHA) + gl.Disable(opengl.CULL_FACE) + gl.Disable(opengl.DEPTH_TEST) + gl.Enable(opengl.SCISSOR_TEST) + gl.PolygonMode(opengl.FRONT_AND_BACK, opengl.FILL) + + // Setup viewport, orthographic projection matrix + gl.Viewport(0, 0, int32(displayWidth), int32(displayHeight)) + orthoProjection := [16]float32{ + 2.0 / float32(displayWidth), 0.0, 0.0, 0.0, + 0.0, 2.0 / float32(-displayHeight), 0.0, 0.0, + 0.0, 0.0, -1.0, 0.0, + -1.0, 1.0, 0.0, 1.0, + } + gl.UseProgram(context.shaderHandle) + gl.Uniform1i(context.attribLocationTex, 0) + gl.Uniform1i(context.attribLocationPal, 1) + gl.UniformMatrix4fv(context.attribLocationProjMtx, false, &orthoProjection) + gl.BindSampler(0, 0) // Rely on combined texture/sampler state. + + vaoHandle := gl.GenVertexArrays(1)[0] + gl.BindVertexArray(vaoHandle) + gl.BindBuffer(opengl.ARRAY_BUFFER, context.vboHandle) + gl.EnableVertexAttribArray(uint32(context.attribLocationPosition)) + gl.EnableVertexAttribArray(uint32(context.attribLocationUV)) + gl.EnableVertexAttribArray(uint32(context.attribLocationColor)) + vertexSize, vertexOffsetPos, vertexOffsetUv, vertexOffsetCol := imgui.VertexBufferLayout() + gl.VertexAttribOffset(uint32(context.attribLocationPosition), 2, opengl.FLOAT, false, int32(vertexSize), vertexOffsetPos) + gl.VertexAttribOffset(uint32(context.attribLocationUV), 2, opengl.FLOAT, false, int32(vertexSize), vertexOffsetUv) + gl.VertexAttribOffset(uint32(context.attribLocationColor), 4, opengl.UNSIGNED_BYTE, true, int32(vertexSize), vertexOffsetCol) + indexSize := imgui.IndexBufferLayout() + drawType := opengl.UNSIGNED_SHORT + if indexSize == 4 { + drawType = opengl.UNSIGNED_INT + } + + // Draw + for _, list := range drawData.CommandLists() { + var indexBufferOffset uintptr + + vertexBuffer, vertexBufferSize := list.VertexBuffer() + gl.BindBuffer(opengl.ARRAY_BUFFER, context.vboHandle) + gl.BufferData(opengl.ARRAY_BUFFER, vertexBufferSize, vertexBuffer, opengl.STREAM_DRAW) + + indexBuffer, indexBufferSize := list.IndexBuffer() + gl.BindBuffer(opengl.ELEMENT_ARRAY_BUFFER, context.elementsHandle) + gl.BufferData(opengl.ELEMENT_ARRAY_BUFFER, indexBufferSize, indexBuffer, opengl.STREAM_DRAW) + + for _, cmd := range list.Commands() { + if cmd.HasUserCallback() { + cmd.CallUserCallback(list) + } else { + textureID := cmd.TextureID() + imageType := ImageTypeFromID(textureID) + gl.Uniform1i(context.attribLocationType, int32(imageType)) + switch imageType { + case ImageTypeSimpleTexture: + gl.ActiveTexture(opengl.TEXTURE0 + uint32(0)) + gl.BindTexture(opengl.TEXTURE_2D, uint32(textureID)) + case ImageTypeColorTexture: + gl.ActiveTexture(opengl.TEXTURE0 + uint32(0)) + gl.BindTexture(opengl.TEXTURE_2D, uint32(textureID)) + case ImageTypeBitmapTexture: + palette, bitmap := bitmapTextureQuery(textureID) + gl.ActiveTexture(opengl.TEXTURE0 + uint32(0)) + gl.BindTexture(opengl.TEXTURE_2D, bitmap) + gl.ActiveTexture(opengl.TEXTURE0 + uint32(1)) + gl.BindTexture(opengl.TEXTURE_2D, palette) + default: + gl.ActiveTexture(opengl.TEXTURE0 + uint32(0)) + gl.BindTexture(opengl.TEXTURE_2D, 0) + gl.ActiveTexture(opengl.TEXTURE0 + uint32(1)) + gl.BindTexture(opengl.TEXTURE_2D, 0) + } + clipRect := cmd.ClipRect() + gl.Scissor(int32(clipRect.X), int32(displayHeight)-int32(clipRect.W), int32(clipRect.Z-clipRect.X), int32(clipRect.W-clipRect.Y)) + gl.DrawElements(opengl.TRIANGLES, int32(cmd.ElementCount()), uint32(drawType), indexBufferOffset) + } + indexBufferOffset += uintptr(cmd.ElementCount() * indexSize) + } + } + gl.DeleteVertexArrays([]uint32{vaoHandle}) + + // Restore modified GL state + gl.UseProgram(uint32(lastProgram)) + gl.BindTexture(opengl.TEXTURE_2D, uint32(lastTexture)) + gl.BindSampler(0, uint32(lastSampler)) + gl.ActiveTexture(uint32(lastActiveTexture)) + gl.BindVertexArray(uint32(lastVertexArray)) + gl.BindBuffer(opengl.ARRAY_BUFFER, uint32(lastArrayBuffer)) + gl.BindBuffer(opengl.ELEMENT_ARRAY_BUFFER, uint32(lastElementArrayBuffer)) + gl.BlendEquationSeparate(uint32(lastBlendEquationRgb), uint32(lastBlendEquationAlpha)) + gl.BlendFuncSeparate(uint32(lastBlendSrcRgb), uint32(lastBlendDstRgb), uint32(lastBlendSrcAlpha), uint32(lastBlendDstAlpha)) + if lastEnableBlend { + gl.Enable(opengl.BLEND) + } else { + gl.Disable(opengl.BLEND) + } + if lastEnableCullFace { + gl.Enable(opengl.CULL_FACE) + } else { + gl.Disable(opengl.CULL_FACE) + } + if lastEnableDepthTest { + gl.Enable(opengl.DEPTH_TEST) + } else { + gl.Disable(opengl.DEPTH_TEST) + } + if lastEnableScissorTest { + gl.Enable(opengl.SCISSOR_TEST) + } else { + gl.Disable(opengl.SCISSOR_TEST) + } + gl.PolygonMode(opengl.FRONT_AND_BACK, uint32(lastPolygonMode[0])) + gl.Viewport(lastViewport[0], lastViewport[1], lastViewport[2], lastViewport[3]) + gl.Scissor(lastScissorBox[0], lastScissorBox[1], lastScissorBox[2], lastScissorBox[3]) +} + +// SetMousePosition must be called to report the current mouse position. +func (context *Context) SetMousePosition(x, y float32) { + imgui.CurrentIO().SetMousePosition(imgui.Vec2{X: x, Y: y}) +} + +// MouseScroll must be called to report the current scroll delta. +func (context *Context) MouseScroll(dx, dy float32) { + imgui.CurrentIO().AddMouseWheelDelta(dx, dy) +} diff --git a/modding/hacked/ui/gui/ImageKey.go b/modding/hacked/ui/gui/ImageKey.go new file mode 100644 index 0000000..7966123 --- /dev/null +++ b/modding/hacked/ui/gui/ImageKey.go @@ -0,0 +1,30 @@ +package gui + +import "github.com/inkyblackness/imgui-go/v3" + +// ImageType is an identifier how an image is to be rendered. +type ImageType byte + +const ( + // ImageTypeSimpleTexture identifies textures that use the image key as OpenGL texture handle. + ImageTypeSimpleTexture ImageType = 0 + // ImageTypeBitmapTexture identifies bitmap textures. + ImageTypeBitmapTexture ImageType = 1 + // ImageTypeColorTexture identifies textures with own color information. + ImageTypeColorTexture ImageType = 2 +) + +// TextureIDForSimpleTexture returns a TextureID with ImageTypeSimpleTexture. +func TextureIDForSimpleTexture(handle uint32) imgui.TextureID { + return imgui.TextureID(ImageTypeSimpleTexture)<<56 | imgui.TextureID(handle) +} + +// TextureIDForColorTexture returns a TextureID with ImageTypeSimpleTexture. +func TextureIDForColorTexture(handle uint32) imgui.TextureID { + return imgui.TextureID(ImageTypeColorTexture)<<56 | imgui.TextureID(handle) +} + +// ImageTypeFromID returns the image type the given texture ID specifies. +func ImageTypeFromID(id imgui.TextureID) ImageType { + return ImageType(id >> 56) +} diff --git a/modding/hacked/ui/gui/ModalState.go b/modding/hacked/ui/gui/ModalState.go new file mode 100644 index 0000000..1824acb --- /dev/null +++ b/modding/hacked/ui/gui/ModalState.go @@ -0,0 +1,13 @@ +package gui + +// ModalState represents a modal dialog. +type ModalState interface { + // Render renders the dialog. + Render() +} + +// FileHandler is accepting filenames. +type FileHandler interface { + // HandleFiles is called for any dropped files. + HandleFiles(names []string) +} diff --git a/modding/hacked/ui/gui/ModalStateMachine.go b/modding/hacked/ui/gui/ModalStateMachine.go new file mode 100644 index 0000000..b6222c0 --- /dev/null +++ b/modding/hacked/ui/gui/ModalStateMachine.go @@ -0,0 +1,6 @@ +package gui + +// ModalStateMachine allows to set a new state. +type ModalStateMachine interface { + SetState(state ModalState) +} diff --git a/modding/hacked/ui/gui/ModalStateWrapper.go b/modding/hacked/ui/gui/ModalStateWrapper.go new file mode 100644 index 0000000..1e96e63 --- /dev/null +++ b/modding/hacked/ui/gui/ModalStateWrapper.go @@ -0,0 +1,28 @@ +package gui + +// ModalStateWrapper is a wrapper over a series of modal popup states. +// It implements both ModalState and ModalStateMachine to allow nested states, if necessary. +type ModalStateWrapper struct { + State ModalState +} + +// SetState sets the new state. +func (machine *ModalStateWrapper) SetState(state ModalState) { + machine.State = state +} + +// Render renders the current state. +func (machine *ModalStateWrapper) Render() { + if machine.State != nil { + machine.State.Render() + } +} + +// HandleFiles forwards the given filenames to the current state. +func (machine *ModalStateWrapper) HandleFiles(filenames []string) { + handler, isHandler := machine.State.(FileHandler) + if !isHandler { + return + } + handler.HandleFiles(filenames) +} diff --git a/modding/hacked/ui/gui/StepSliderInt.go b/modding/hacked/ui/gui/StepSliderInt.go new file mode 100644 index 0000000..ad120b2 --- /dev/null +++ b/modding/hacked/ui/gui/StepSliderInt.go @@ -0,0 +1,42 @@ +package gui + +import ( + "github.com/inkyblackness/imgui-go/v3" +) + +// StepSliderIntV creates a SliderInt with additional buttons to make single digit steps. +// min and max parameters are inclusive. Returns true on change. +func StepSliderIntV(label string, value *int, min, max int, format string) bool { + imgui.PushID(label) + changed := false + if imgui.Button("-") && (*value > min) { + *value-- + changed = true + } + innerSpacing := imgui.CurrentStyle().ItemInnerSpacing() + imgui.SameLineV(0, innerSpacing.X) + if imgui.Button("+") && (*value < max) { + *value++ + changed = true + } + imgui.SameLineV(0, innerSpacing.X) + value32 := int32(*value) + if imgui.SliderIntV(label, &value32, int32(min), int32(max), format, imgui.SlidersFlagsNone) { + switch { + case int(value32) < min: + *value = min + case int(value32) > max: + *value = max + default: + *value = int(value32) + } + changed = true + } + imgui.PopID() + return changed +} + +// StepSliderInt calls StepSliderIntV(label, value, min, max, "%d"). +func StepSliderInt(label string, value *int, min, max int) bool { + return StepSliderIntV(label, value, min, max, "%d") +} diff --git a/modding/hacked/ui/input/Key.go b/modding/hacked/ui/input/Key.go new file mode 100644 index 0000000..85291bf --- /dev/null +++ b/modding/hacked/ui/input/Key.go @@ -0,0 +1,95 @@ +package input + +// Key describes a named key on the keyboard. These are keys which are +// unspecific to layout or language, or are universal. +// Or, described in another way: keys that don't end up as printable characters. +type Key int + +// Constants for commonly known named keys. +const ( + KeyEnter = Key(300) + KeyEscape = Key(301) + KeyBackspace = Key(302) + KeyTab = Key(303) + + KeyDown = Key(310) + KeyLeft = Key(311) + KeyRight = Key(312) + KeyUp = Key(313) + + KeyDelete = Key(320) + KeyEnd = Key(321) + KeyHome = Key(322) + KeyInsert = Key(323) + KeyPageDown = Key(324) + KeyPageUp = Key(325) + + KeyAlt = Key(330) + KeyControl = Key(331) + KeyShift = Key(332) + KeySuper = Key(333) + + KeyPause = Key(340) + KeyPrintScreen = Key(341) + KeyCapsLock = Key(342) + KeyScrollLock = Key(343) + + KeyF1 = Key(351) + KeyF10 = Key(360) + KeyF11 = Key(361) + KeyF12 = Key(362) + KeyF13 = Key(363) + KeyF14 = Key(364) + KeyF15 = Key(365) + KeyF16 = Key(366) + KeyF17 = Key(367) + KeyF18 = Key(368) + KeyF19 = Key(369) + KeyF2 = Key(352) + KeyF20 = Key(370) + KeyF21 = Key(371) + KeyF22 = Key(372) + KeyF23 = Key(373) + KeyF24 = Key(374) + KeyF25 = Key(375) + KeyF3 = Key(353) + KeyF4 = Key(354) + KeyF5 = Key(355) + KeyF6 = Key(356) + KeyF7 = Key(357) + KeyF8 = Key(358) + KeyF9 = Key(359) + + KeyCopy = Key(380) + KeyCut = Key(381) + KeyPaste = Key(382) + + KeyUndo = Key(390) + KeyRedo = Key(391) + + KeySave = Key(400) + KeyNew = Key(401) + + KeyA = Key(410) + KeyC = Key(411) + KeyV = Key(412) + KeyX = Key(413) +) + +var keyToModifier = map[Key]Modifier{ + KeyShift: ModShift, + KeyControl: ModControl, + KeyAlt: ModAlt, + KeySuper: ModSuper, +} + +// AsModifier returns the modifier equivalent for the key - if applicable. +func (key Key) AsModifier() Modifier { + mod, isModifier := keyToModifier[key] + + if !isModifier { + mod = ModNone + } + + return mod +} diff --git a/modding/hacked/ui/input/Modifier.go b/modding/hacked/ui/input/Modifier.go new file mode 100644 index 0000000..2269651 --- /dev/null +++ b/modding/hacked/ui/input/Modifier.go @@ -0,0 +1,34 @@ +package input + +// Modifier is a collection of currently pressed modifier keys. +type Modifier uint32 + +// Constants for common modifier. +const ( + ModNone = Modifier(0x00000000) + ModShift = Modifier(0x00000001) + ModControl = Modifier(0x00000002) + ModAlt = Modifier(0x00000004) + ModSuper = Modifier(0x00000008) +) + +// IsClear returns true if nothing is set. +func (mod Modifier) IsClear() bool { + return mod == ModNone +} + +// With returns a combination of this modifier and the given one. +func (mod Modifier) With(other Modifier) Modifier { + return Modifier(uint32(mod) | uint32(other)) +} + +// Without returns the result of this modifier without the given one. +func (mod Modifier) Without(other Modifier) Modifier { + return Modifier(uint32(mod) & ^uint32(other)) +} + +// Has returns true if the given modifier is included in this set. +// This modifier can have more keys set than the requested and still return true. +func (mod Modifier) Has(other Modifier) bool { + return (uint32(mod) | uint32(other)) == uint32(mod) +} diff --git a/modding/hacked/ui/input/Modifier_test.go b/modding/hacked/ui/input/Modifier_test.go new file mode 100644 index 0000000..bceec20 --- /dev/null +++ b/modding/hacked/ui/input/Modifier_test.go @@ -0,0 +1,41 @@ +package input_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ui/input" + + "github.com/stretchr/testify/assert" +) + +func TestHasReturnsTrueForThemselves(t *testing.T) { + mods := []input.Modifier{input.ModShift, input.ModControl, input.ModAlt, input.ModSuper} + + for _, mod := range mods { + assert.True(t, mod.Has(mod)) + } +} + +func TestHasReturnsFalseForDifferentModifiers(t *testing.T) { + mods := []input.Modifier{input.ModShift, input.ModControl, input.ModAlt, input.ModSuper} + + for index, mod := range mods { + nextMod := mods[(index+1)%len(mods)] + assert.False(t, mod.Has(nextMod)) + } +} + +func TestHasReturnsFalseForSetsIncludingOthers(t *testing.T) { + mods := []input.Modifier{input.ModShift, input.ModControl, input.ModAlt, input.ModSuper} + + for index, mod := range mods { + nextMod := mods[(index+1)%len(mods)] + assert.False(t, mod.Has(mod.With(nextMod))) + } +} + +func TestWithoutReturnsReduction(t *testing.T) { + mod := input.ModShift.With(input.ModAlt).Without(input.ModShift) + + assert.Equal(t, input.ModAlt, mod) +} diff --git a/modding/hacked/ui/input/Shortcut.go b/modding/hacked/ui/input/Shortcut.go new file mode 100644 index 0000000..c9e3ba0 --- /dev/null +++ b/modding/hacked/ui/input/Shortcut.go @@ -0,0 +1,34 @@ +package input + +import "strings" + +type shortcut struct { + keyName string + modifier Modifier + key Key +} + +var shortcuts = []shortcut{ + {keyName: "c", modifier: ModControl, key: KeyCopy}, + {keyName: "x", modifier: ModControl, key: KeyCut}, + {keyName: "v", modifier: ModControl, key: KeyPaste}, + {keyName: "z", modifier: ModControl, key: KeyUndo}, + {keyName: "z", modifier: ModControl.With(ModShift), key: KeyRedo}, + {keyName: "y", modifier: ModControl, key: KeyRedo}, + {keyName: "s", modifier: ModControl, key: KeySave}, + {keyName: "n", modifier: ModControl, key: KeyNew}, +} + +// ResolveShortcut tries to map the given name and modifier combination to a +// known (common) shortcut key. For instance, Ctrl+C is KeyCopy. +func ResolveShortcut(keyName string, modifier Modifier) (key Key, knownKey bool) { + lowercaseName := strings.ToLower(keyName) + for _, entry := range shortcuts { + if (entry.keyName == lowercaseName) && (entry.modifier == modifier) { + knownKey = true + key = entry.key + } + } + + return +} diff --git a/modding/hacked/ui/input/Shortcut_test.go b/modding/hacked/ui/input/Shortcut_test.go new file mode 100644 index 0000000..9cad16e --- /dev/null +++ b/modding/hacked/ui/input/Shortcut_test.go @@ -0,0 +1,22 @@ +package input_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ui/input" + + "github.com/stretchr/testify/assert" +) + +func TestResolveShortcutReturnsValuesOfKnownCombo(t *testing.T) { + key, knownKey := input.ResolveShortcut("c", input.ModControl) + + assert.True(t, knownKey) + assert.Equal(t, input.KeyCopy, key) +} + +func TestResolveShortcutReturnsFalseForUnknownCombo(t *testing.T) { + _, knownKey := input.ResolveShortcut("l", input.ModControl.With(input.ModShift)) + + assert.False(t, knownKey) +} diff --git a/modding/hacked/ui/input/StickyKeyBuffer.go b/modding/hacked/ui/input/StickyKeyBuffer.go new file mode 100644 index 0000000..9c542ac --- /dev/null +++ b/modding/hacked/ui/input/StickyKeyBuffer.go @@ -0,0 +1,101 @@ +package input + +// StickyKeyListener is the listener interface for receiving key events. +type StickyKeyListener interface { + // KeyPress is called for a pressed key. + KeyPress(key Key, modifier Modifier) + // KeyRelease is called for a released key. + KeyRelease(key Key, modifier Modifier) + // Modifier is called when the currently active modifier changed. + Modifier(modifier Modifier) +} + +// StickyKeyBuffer is a buffer to keep track of several identically named keys. +// Keys can be reported being pressed or released. Their state will be forwarded +// to a StickyKeyListener instance. If a specific key is reported to be pressed +// more than once, the listener will have received the down state only once. +type StickyKeyBuffer struct { + pressedKeys map[Key]int + pressedModifier map[Modifier]int + activeModifier Modifier + listener StickyKeyListener +} + +// NewStickyKeyBuffer returns a new instance of a sticky key buffer. +func NewStickyKeyBuffer(listener StickyKeyListener) *StickyKeyBuffer { + buffer := &StickyKeyBuffer{ + pressedKeys: make(map[Key]int), + pressedModifier: make(map[Modifier]int), + activeModifier: ModNone, + listener: listener, + } + + return buffer +} + +// ActiveModifier returns the currently pressed modifier set. +func (buffer *StickyKeyBuffer) ActiveModifier() Modifier { + return buffer.activeModifier +} + +// KeyDown registers a pressed key state. Multiple down states can be +// registered for the same key and result in only one key event. +func (buffer *StickyKeyBuffer) KeyDown(key Key, modifier Modifier) { + keyAsModifier := key.AsModifier() + updatedModifier := modifier.With(keyAsModifier) + + oldCount := buffer.pressedKeys[key] + buffer.pressedKeys[key] = oldCount + 1 + if oldCount == 0 { + buffer.listener.KeyPress(key, updatedModifier) + } + + buffer.setActiveModifier(updatedModifier) + if keyAsModifier != ModNone { + buffer.pressedModifier[keyAsModifier]++ + } +} + +// KeyUp registers a released key state. Multiple up states can be registered +// for the same key, as long as enough down states were reported. +func (buffer *StickyKeyBuffer) KeyUp(key Key, modifier Modifier) { + updatedModifier := modifier + keyAsModifier := key.AsModifier() + if keyAsModifier != ModNone { + oldCount := buffer.pressedModifier[keyAsModifier] + if oldCount > 0 { + buffer.pressedModifier[keyAsModifier] = oldCount - 1 + if oldCount == 1 { + updatedModifier = updatedModifier.Without(keyAsModifier) + } + } + } + + oldCount := buffer.pressedKeys[key] + if oldCount > 0 { + buffer.pressedKeys[key] = oldCount - 1 + if oldCount == 1 { + buffer.listener.KeyRelease(key, updatedModifier) + } + } + buffer.setActiveModifier(updatedModifier) +} + +// ReleaseAll notifies the listener of the reset of all modifiers. +// Key states are reset to accept new down states. +func (buffer *StickyKeyBuffer) ReleaseAll() { + buffer.pressedKeys = make(map[Key]int) + buffer.setActiveModifier(ModNone) +} + +func (buffer *StickyKeyBuffer) setActiveModifier(modifier Modifier) { + if buffer.activeModifier != modifier { + for mod := range buffer.pressedModifier { + if !modifier.Has(mod) { + buffer.pressedModifier[mod] = 0 + } + } + buffer.activeModifier = modifier + buffer.listener.Modifier(buffer.activeModifier) + } +} diff --git a/modding/hacked/ui/input/StickyKeyBuffer_test.go b/modding/hacked/ui/input/StickyKeyBuffer_test.go new file mode 100644 index 0000000..5be51c5 --- /dev/null +++ b/modding/hacked/ui/input/StickyKeyBuffer_test.go @@ -0,0 +1,261 @@ +package input_test + +import ( + "testing" + + "github.com/inkyblackness/hacked/ui/input" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type keyEvent struct { + event string + key input.Key + mod input.Modifier +} + +type testingStickyKeysListener struct { + eventMap map[input.Key][]keyEvent + events []keyEvent +} + +func (listener *testingStickyKeysListener) KeyPress(key input.Key, modifier input.Modifier) { + listener.addEvent(keyEvent{event: "P", key: key, mod: modifier}) +} + +func (listener *testingStickyKeysListener) KeyRelease(key input.Key, modifier input.Modifier) { + listener.addEvent(keyEvent{event: "R", key: key, mod: modifier}) +} + +func (listener *testingStickyKeysListener) Modifier(modifier input.Modifier) { + listener.addEvent(keyEvent{event: "M", key: 0, mod: modifier}) +} + +func (listener *testingStickyKeysListener) addEvent(event keyEvent) { + listener.eventMap[event.key] = append(listener.eventMap[event.key], event) + listener.events = append(listener.events, event) +} + +type StickyKeyBufferSuite struct { + suite.Suite + buffer *input.StickyKeyBuffer + listener *testingStickyKeysListener +} + +func TestProviderBackedStoreSuite(t *testing.T) { + suite.Run(t, new(StickyKeyBufferSuite)) +} + +func (suite *StickyKeyBufferSuite) SetupTest() { + suite.listener = &testingStickyKeysListener{ + eventMap: make(map[input.Key][]keyEvent)} + suite.buffer = input.NewStickyKeyBuffer(suite.listener) +} + +func (suite *StickyKeyBufferSuite) TestRegularEventsAreForwarded_A() { + suite.buffer.KeyDown(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + suite.buffer.KeyDown(input.KeyF2, input.ModNone) + suite.buffer.KeyUp(input.KeyF2, input.ModNone) + + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyF1]) + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF2, mod: input.ModNone}, + {event: "R", key: input.KeyF2, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyF2]) +} + +func (suite *StickyKeyBufferSuite) TestRegularEventsAreForwarded_B() { + suite.buffer.KeyDown(input.KeyF1, input.ModNone) + suite.buffer.KeyDown(input.KeyF2, input.ModNone) + suite.buffer.KeyUp(input.KeyF2, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyF1]) + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF2, mod: input.ModNone}, + {event: "R", key: input.KeyF2, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyF2]) + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "P", key: input.KeyF2, mod: input.ModNone}, + {event: "R", key: input.KeyF2, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + }, suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestIdenticalKeysAreReportedOnlyOnce() { + suite.buffer.KeyDown(input.KeyF1, input.ModNone) + suite.buffer.KeyDown(input.KeyF1, input.ModNone) + suite.buffer.KeyDown(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyF1]) + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + }, suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestSuperfluousReleasesAreIgnored() { + suite.buffer.KeyDown(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + suite.buffer.KeyDown(input.KeyF2, input.ModNone) + suite.buffer.KeyDown(input.KeyF1, input.ModNone) + suite.buffer.KeyUp(input.KeyF1, input.ModNone) + + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyF1]) + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + {event: "P", key: input.KeyF2, mod: input.ModNone}, + {event: "P", key: input.KeyF1, mod: input.ModNone}, + {event: "R", key: input.KeyF1, mod: input.ModNone}, + }, suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestReleaseAllResetsRegularKeys() { + suite.buffer.KeyDown(input.KeyEnter, input.ModNone) + suite.buffer.KeyDown(input.KeyEnter, input.ModNone) + suite.buffer.KeyDown(input.KeyTab, input.ModNone) + suite.buffer.ReleaseAll() + suite.buffer.KeyDown(input.KeyTab, input.ModNone) + + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyEnter, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyEnter]) + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyTab, mod: input.ModNone}, + {event: "P", key: input.KeyTab, mod: input.ModNone}, + }, suite.listener.eventMap[input.KeyTab]) +} + +func (suite *StickyKeyBufferSuite) TestModifierEventsAreConvertedToModifierStates_DownAugmented() { + suite.buffer.KeyDown(input.KeyShift, input.ModShift) + suite.buffer.KeyDown(input.KeyAlt, input.ModShift.With(input.ModAlt)) + suite.buffer.KeyUp(input.KeyAlt, input.ModShift) + suite.buffer.KeyUp(input.KeyShift, input.ModNone) + + assert.Equal(suite.T(), 2, len(suite.listener.eventMap[input.KeyShift])) + assert.Equal(suite.T(), 2, len(suite.listener.eventMap[input.KeyAlt])) + assert.Equal(suite.T(), + []keyEvent{ + {event: "P", key: input.KeyShift, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + {event: "P", key: input.KeyAlt, mod: input.ModShift.With(input.ModAlt)}, + {event: "M", key: 0, mod: input.ModShift.With(input.ModAlt)}, + {event: "R", key: input.KeyAlt, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + {event: "R", key: input.KeyShift, mod: input.ModNone}, + {event: "M", key: 0, mod: input.ModNone}, + }, + suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestModifierEventsAreConvertedToModifierStates_UpAugmented() { + suite.buffer.KeyDown(input.KeyShift, input.ModNone) + suite.buffer.KeyDown(input.KeyAlt, input.ModShift) + suite.buffer.KeyUp(input.KeyAlt, input.ModShift.With(input.ModAlt)) + suite.buffer.KeyUp(input.KeyShift, input.ModShift) + + assert.Equal(suite.T(), 2, len(suite.listener.eventMap[input.KeyShift])) + assert.Equal(suite.T(), 2, len(suite.listener.eventMap[input.KeyAlt])) + assert.Equal(suite.T(), + []keyEvent{ + {event: "P", key: input.KeyShift, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + {event: "P", key: input.KeyAlt, mod: input.ModShift.With(input.ModAlt)}, + {event: "M", key: 0, mod: input.ModShift.With(input.ModAlt)}, + {event: "R", key: input.KeyAlt, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + {event: "R", key: input.KeyShift, mod: input.ModNone}, + {event: "M", key: 0, mod: input.ModNone}, + }, + suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestReleaseAllReleasesModifiers() { + suite.buffer.KeyDown(input.KeyShift, input.ModNone) + suite.buffer.KeyDown(input.KeyShift, input.ModNone) + suite.buffer.KeyDown(input.KeyAlt, input.ModShift) + suite.buffer.ReleaseAll() + + assert.Equal(suite.T(), + []keyEvent{ + {event: "P", key: input.KeyShift, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + {event: "P", key: input.KeyAlt, mod: input.ModShift.With(input.ModAlt)}, + {event: "M", key: 0, mod: input.ModShift.With(input.ModAlt)}, + {event: "M", key: 0, mod: input.ModNone}, + }, + suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestActiveModifierReturnsCurrentModifier() { + suite.buffer.KeyDown(input.KeyShift, input.ModNone) + suite.buffer.KeyDown(input.KeyControl, input.ModShift) + suite.buffer.KeyDown(input.KeyAlt, input.ModShift.With(input.ModControl)) + suite.buffer.KeyUp(input.KeyControl, input.ModShift.With(input.ModControl).With(input.ModAlt)) + + assert.Equal(suite.T(), input.ModShift.With(input.ModAlt), suite.buffer.ActiveModifier()) +} + +func (suite *StickyKeyBufferSuite) TestModifierAreUpdatedOnOtherKeys_A() { + suite.buffer.KeyDown(input.KeyShift, input.ModShift) + suite.buffer.KeyDown(input.KeyTab, input.ModNone) + + assert.Equal(suite.T(), + []keyEvent{ + {event: "P", key: input.KeyShift, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + {event: "P", key: input.KeyTab, mod: input.ModNone}, + {event: "M", key: 0, mod: input.ModNone}, + }, + suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestModifierAreUpdatedOnOtherKeys_B() { + suite.buffer.KeyDown(input.KeyTab, input.ModNone) + suite.buffer.KeyDown(input.KeyShift, input.ModShift) + suite.buffer.KeyUp(input.KeyTab, input.ModNone) + + assert.Equal(suite.T(), + []keyEvent{ + {event: "P", key: input.KeyTab, mod: input.ModNone}, + {event: "P", key: input.KeyShift, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + {event: "R", key: input.KeyTab, mod: input.ModNone}, + {event: "M", key: 0, mod: input.ModNone}, + }, + suite.listener.events) +} + +func (suite *StickyKeyBufferSuite) TestModifierAreUpdatedOnOtherKeysWithoutThemselves() { + suite.buffer.KeyDown(input.KeyShift, input.ModShift) + suite.buffer.KeyDown(input.KeyShift, input.ModShift) + suite.buffer.KeyUp(input.KeyShift, input.ModShift) + + assert.Equal(suite.T(), []keyEvent{ + {event: "P", key: input.KeyShift, mod: input.ModShift}, + {event: "M", key: 0, mod: input.ModShift}, + }, suite.listener.events) +} diff --git a/modding/hacked/ui/input/constants.go b/modding/hacked/ui/input/constants.go new file mode 100644 index 0000000..c3492a6 --- /dev/null +++ b/modding/hacked/ui/input/constants.go @@ -0,0 +1,8 @@ +package input + +const ( + // MousePrimary specifies the primary mouse button (by default, the left one). + MousePrimary = uint32(1) + // MouseSecondary specifies the secondary mouse button (by default, the right one). + MouseSecondary = uint32(2) +) diff --git a/modding/hacked/ui/native/NativeKeys.go b/modding/hacked/ui/native/NativeKeys.go new file mode 100644 index 0000000..700f79c --- /dev/null +++ b/modding/hacked/ui/native/NativeKeys.go @@ -0,0 +1,73 @@ +package native + +import ( + "github.com/go-gl/glfw/v3.2/glfw" + + "github.com/inkyblackness/hacked/ui/input" +) + +var keyMap = map[glfw.Key]input.Key{ + glfw.KeyEnter: input.KeyEnter, + glfw.KeyKPEnter: input.KeyEnter, + glfw.KeyEscape: input.KeyEscape, + glfw.KeyBackspace: input.KeyBackspace, + glfw.KeyTab: input.KeyTab, + + glfw.KeyDown: input.KeyDown, + glfw.KeyLeft: input.KeyLeft, + glfw.KeyRight: input.KeyRight, + glfw.KeyUp: input.KeyUp, + + glfw.KeyDelete: input.KeyDelete, + glfw.KeyEnd: input.KeyEnd, + glfw.KeyHome: input.KeyHome, + glfw.KeyInsert: input.KeyInsert, + glfw.KeyPageDown: input.KeyPageDown, + glfw.KeyPageUp: input.KeyPageUp, + + glfw.KeyLeftAlt: input.KeyAlt, + glfw.KeyLeftControl: input.KeyControl, + glfw.KeyLeftShift: input.KeyShift, + glfw.KeyLeftSuper: input.KeySuper, + glfw.KeyRightAlt: input.KeyAlt, + glfw.KeyRightControl: input.KeyControl, + glfw.KeyRightShift: input.KeyShift, + glfw.KeyRightSuper: input.KeySuper, + + glfw.KeyPause: input.KeyPause, + glfw.KeyPrintScreen: input.KeyPrintScreen, + + glfw.KeyCapsLock: input.KeyCapsLock, + glfw.KeyScrollLock: input.KeyScrollLock, + + glfw.KeyF1: input.KeyF1, + glfw.KeyF10: input.KeyF10, + glfw.KeyF11: input.KeyF11, + glfw.KeyF12: input.KeyF12, + glfw.KeyF13: input.KeyF13, + glfw.KeyF14: input.KeyF14, + glfw.KeyF15: input.KeyF15, + glfw.KeyF16: input.KeyF16, + glfw.KeyF17: input.KeyF17, + glfw.KeyF18: input.KeyF18, + glfw.KeyF19: input.KeyF19, + glfw.KeyF2: input.KeyF2, + glfw.KeyF20: input.KeyF20, + glfw.KeyF21: input.KeyF21, + glfw.KeyF22: input.KeyF22, + glfw.KeyF23: input.KeyF23, + glfw.KeyF24: input.KeyF24, + glfw.KeyF25: input.KeyF25, + glfw.KeyF3: input.KeyF3, + glfw.KeyF4: input.KeyF4, + glfw.KeyF5: input.KeyF5, + glfw.KeyF6: input.KeyF6, + glfw.KeyF7: input.KeyF7, + glfw.KeyF8: input.KeyF8, + glfw.KeyF9: input.KeyF9, + + glfw.KeyA: input.KeyA, + glfw.KeyC: input.KeyC, + glfw.KeyV: input.KeyV, + glfw.KeyX: input.KeyX, +} diff --git a/modding/hacked/ui/native/OpenGl.go b/modding/hacked/ui/native/OpenGl.go new file mode 100644 index 0000000..a1d0f44 --- /dev/null +++ b/modding/hacked/ui/native/OpenGl.go @@ -0,0 +1,378 @@ +package native + +import ( + "strings" + "unsafe" + + "github.com/go-gl/gl/v3.2-core/gl" +) + +// OpenGL wraps the native GL API into a common interface. +type OpenGL struct { +} + +// NewOpenGL initializes the Gl bindings and returns an OpenGL instance. +func NewOpenGL() *OpenGL { + opengl := &OpenGL{} + + if err := gl.Init(); err != nil { + panic(err) + } + + return opengl +} + +// ActiveTexture implements the opengl.OpenGL interface. +func (native *OpenGL) ActiveTexture(texture uint32) { + gl.ActiveTexture(texture) +} + +// AttachShader implements the opengl.OpenGL interface. +func (native *OpenGL) AttachShader(program uint32, shader uint32) { + gl.AttachShader(program, shader) +} + +// BindAttribLocation implements the opengl.OpenGL interface. +func (native *OpenGL) BindAttribLocation(program uint32, index uint32, name string) { + gl.BindAttribLocation(program, index, gl.Str(name+"\x00")) +} + +// BindBuffer implements the opengl.OpenGL interface. +func (native *OpenGL) BindBuffer(target uint32, buffer uint32) { + gl.BindBuffer(target, buffer) +} + +// BindFramebuffer implements the opengl.OpenGL interface. +func (native *OpenGL) BindFramebuffer(target uint32, buffer uint32) { + gl.BindFramebuffer(target, buffer) +} + +// BindRenderbuffer implements the opengl.OpenGL interface. +func (native *OpenGL) BindRenderbuffer(target uint32, buffer uint32) { + gl.BindRenderbuffer(target, buffer) +} + +// BindSampler implements the opengl.OpenGL interface. +func (native *OpenGL) BindSampler(unit uint32, sampler uint32) { + gl.BindSampler(unit, sampler) +} + +// BindTexture implements the opengl.OpenGL interface. +func (native *OpenGL) BindTexture(target uint32, texture uint32) { + gl.BindTexture(target, texture) +} + +// BindVertexArray implements the opengl.OpenGL interface. +func (native *OpenGL) BindVertexArray(array uint32) { + gl.BindVertexArray(array) +} + +// BlendEquation implements the opengl.OpenGL interface. +func (native *OpenGL) BlendEquation(mode uint32) { + gl.BlendEquation(mode) +} + +// BlendEquationSeparate implements the opengl.OpenGL interface. +func (native *OpenGL) BlendEquationSeparate(modeRGB uint32, modeAlpha uint32) { + gl.BlendEquationSeparate(modeRGB, modeAlpha) +} + +// BlendFunc implements the opengl.OpenGL interface. +func (native *OpenGL) BlendFunc(sfactor uint32, dfactor uint32) { + gl.BlendFunc(sfactor, dfactor) +} + +// BlendFuncSeparate implements the opengl.OpenGL interface. +func (native *OpenGL) BlendFuncSeparate(srcRGB uint32, dstRGB uint32, srcAlpha uint32, dstAlpha uint32) { + gl.BlendFuncSeparate(srcRGB, dstRGB, srcAlpha, dstAlpha) +} + +// BufferData implements the opengl.OpenGL interface. +func (native *OpenGL) BufferData(target uint32, size int, data interface{}, usage uint32) { + dataPtr, isPtr := data.(unsafe.Pointer) + if isPtr { + gl.BufferData(target, size, dataPtr, usage) + } else { + gl.BufferData(target, size, gl.Ptr(data), usage) + } +} + +// CheckFramebufferStatus implements the opengl.OpenGL interface. +func (native *OpenGL) CheckFramebufferStatus(target uint32) uint32 { + return gl.CheckFramebufferStatus(target) +} + +// Clear implements the opengl.OpenGL interface. +func (native *OpenGL) Clear(mask uint32) { + gl.Clear(mask) +} + +// ClearColor implements the opengl.OpenGL interface. +func (native *OpenGL) ClearColor(red float32, green float32, blue float32, alpha float32) { + gl.ClearColor(red, green, blue, alpha) +} + +// CompileShader implements the opengl.OpenGL interface. +func (native *OpenGL) CompileShader(shader uint32) { + gl.CompileShader(shader) +} + +// CreateProgram implements the opengl.OpenGL interface. +func (native *OpenGL) CreateProgram() uint32 { + return gl.CreateProgram() +} + +// CreateShader implements the opengl.OpenGL interface. +func (native *OpenGL) CreateShader(shaderType uint32) uint32 { + return gl.CreateShader(shaderType) +} + +// DeleteBuffers implements the opengl.OpenGL interface. +func (native *OpenGL) DeleteBuffers(buffers []uint32) { + gl.DeleteBuffers(int32(len(buffers)), &buffers[0]) +} + +// DeleteFramebuffers implements the opengl.OpenGL interface. +func (native *OpenGL) DeleteFramebuffers(buffers []uint32) { + gl.DeleteFramebuffers(int32(len(buffers)), &buffers[0]) +} + +// DeleteProgram implements the opengl.OpenGL interface. +func (native *OpenGL) DeleteProgram(program uint32) { + gl.DeleteProgram(program) +} + +// DeleteShader implements the opengl.OpenGL interface. +func (native *OpenGL) DeleteShader(shader uint32) { + gl.DeleteShader(shader) +} + +// DeleteTextures implements the opengl.OpenGL interface. +func (native *OpenGL) DeleteTextures(textures []uint32) { + gl.DeleteTextures(int32(len(textures)), &textures[0]) +} + +// DeleteVertexArrays implements the opengl.OpenGL interface. +func (native *OpenGL) DeleteVertexArrays(arrays []uint32) { + gl.DeleteVertexArrays(int32(len(arrays)), &arrays[0]) +} + +// Disable implements the opengl.OpenGL interface. +func (native *OpenGL) Disable(capability uint32) { + gl.Disable(capability) +} + +// DrawArrays implements the opengl.OpenGL interface. +func (native *OpenGL) DrawArrays(mode uint32, first int32, count int32) { + gl.DrawArrays(mode, first, count) +} + +// DrawBuffers implements the opengl.OpenGL interface. +func (native *OpenGL) DrawBuffers(buffers []uint32) { + gl.DrawBuffers(int32(len(buffers)), &buffers[0]) +} + +// DrawElements implements the opengl.OpenGL interface. +func (native *OpenGL) DrawElements(mode uint32, count int32, elementType uint32, indices uintptr) { + gl.DrawElements(mode, count, elementType, unsafe.Pointer(indices)) // nolint: govet,gas +} + +// Enable implements the opengl.OpenGL interface. +func (native *OpenGL) Enable(capability uint32) { + gl.Enable(capability) +} + +// EnableVertexAttribArray implements the opengl.OpenGL interface. +func (native *OpenGL) EnableVertexAttribArray(index uint32) { + gl.EnableVertexAttribArray(index) +} + +// FramebufferRenderbuffer implements the opengl.OpenGL interface. +func (native *OpenGL) FramebufferRenderbuffer(target uint32, attachment uint32, renderbuffertarget uint32, renderbuffer uint32) { + gl.FramebufferRenderbuffer(target, attachment, renderbuffertarget, renderbuffer) +} + +// FramebufferTexture implements the opengl.OpenGL interface. +func (native *OpenGL) FramebufferTexture(target uint32, attachment uint32, texture uint32, level int32) { + gl.FramebufferTexture(target, attachment, texture, level) +} + +// GenerateMipmap implements the opengl.OpenGL interface. +func (native *OpenGL) GenerateMipmap(target uint32) { + gl.GenerateMipmap(target) +} + +// GenBuffers implements the opengl.OpenGL interface. +func (native *OpenGL) GenBuffers(n int32) []uint32 { + buffers := make([]uint32, n) + gl.GenBuffers(n, &buffers[0]) + return buffers +} + +// GenFramebuffers implements the opengl.OpenGL interface. +func (native *OpenGL) GenFramebuffers(n int32) []uint32 { + buffers := make([]uint32, n) + gl.GenFramebuffers(n, &buffers[0]) + return buffers +} + +// GenRenderbuffers implements the opengl.OpenGL interface. +func (native *OpenGL) GenRenderbuffers(n int32) []uint32 { + buffers := make([]uint32, n) + gl.GenRenderbuffers(n, &buffers[0]) + return buffers +} + +// GenTextures implements the opengl.OpenGL interface. +func (native *OpenGL) GenTextures(n int32) []uint32 { + ids := make([]uint32, n) + gl.GenTextures(n, &ids[0]) + return ids +} + +// GenVertexArrays implements the opengl.OpenGL interface. +func (native *OpenGL) GenVertexArrays(n int32) []uint32 { + ids := make([]uint32, n) + gl.GenVertexArrays(n, &ids[0]) + return ids +} + +// GetAttribLocation implements the opengl.OpenGL interface. +func (native *OpenGL) GetAttribLocation(program uint32, name string) int32 { + return gl.GetAttribLocation(program, gl.Str(name+"\x00")) +} + +// GetError implements the opengl.OpenGL interface. +func (native *OpenGL) GetError() uint32 { + return gl.GetError() +} + +// GetIntegerv implements the opengl.OpenGL interface. +func (native *OpenGL) GetIntegerv(name uint32, data *int32) { + gl.GetIntegerv(name, data) +} + +// GetProgramInfoLog implements the opengl.OpenGL interface. +func (native *OpenGL) GetProgramInfoLog(program uint32) string { + logLength := native.GetProgramParameter(program, gl.INFO_LOG_LENGTH) + log := strings.Repeat("\x00", int(logLength+1)) + gl.GetProgramInfoLog(program, logLength, nil, gl.Str(log)) + return log +} + +// GetProgramParameter implements the opengl.OpenGL interface. +func (native *OpenGL) GetProgramParameter(program uint32, param uint32) int32 { + result := int32(0) + gl.GetProgramiv(program, param, &result) + return result +} + +// GetShaderInfoLog implements the opengl.OpenGL interface. +func (native *OpenGL) GetShaderInfoLog(shader uint32) string { + logLength := native.GetShaderParameter(shader, gl.INFO_LOG_LENGTH) + log := strings.Repeat("\x00", int(logLength+1)) + gl.GetShaderInfoLog(shader, logLength, nil, gl.Str(log)) + return log +} + +// GetShaderParameter implements the opengl.OpenGL interface. +func (native *OpenGL) GetShaderParameter(shader uint32, param uint32) int32 { + result := int32(0) + gl.GetShaderiv(shader, param, &result) + return result +} + +// GetUniformLocation implements the opengl.OpenGL interface. +func (native *OpenGL) GetUniformLocation(program uint32, name string) int32 { + return gl.GetUniformLocation(program, gl.Str(name+"\x00")) +} + +// IsEnabled implements the OpenGL interface. +func (native *OpenGL) IsEnabled(capability uint32) bool { + return gl.IsEnabled(capability) +} + +// LinkProgram implements the opengl.OpenGL interface. +func (native *OpenGL) LinkProgram(program uint32) { + gl.LinkProgram(program) +} + +// PixelStorei implements the OpenGL interface. +func (native *OpenGL) PixelStorei(name uint32, param int32) { + gl.PixelStorei(name, param) +} + +// PolygonMode implements the opengl.OpenGL interface. +func (native *OpenGL) PolygonMode(face uint32, mode uint32) { + gl.PolygonMode(face, mode) +} + +// ReadPixels implements the opengl.OpenGL interface. +func (native *OpenGL) ReadPixels(x int32, y int32, width int32, height int32, format uint32, pixelType uint32, pixels interface{}) { + gl.ReadPixels(x, y, width, height, format, pixelType, gl.Ptr(pixels)) +} + +// RenderbufferStorage implements the opengl.OpenGL interface. +func (native *OpenGL) RenderbufferStorage(target uint32, internalFormat uint32, width int32, height int32) { + gl.RenderbufferStorage(target, internalFormat, width, height) +} + +// Scissor implements the opengl.OpenGL interface. +func (native *OpenGL) Scissor(x, y int32, width, height int32) { + gl.Scissor(x, y, width, height) +} + +// ShaderSource implements the opengl.OpenGL interface. +func (native *OpenGL) ShaderSource(shader uint32, source string) { + csources, free := gl.Strs(source + "\x00") + defer free() + + gl.ShaderSource(shader, 1, csources, nil) +} + +// TexImage2D implements the opengl.OpenGL interface. +func (native *OpenGL) TexImage2D(target uint32, level int32, internalFormat uint32, width int32, height int32, + border int32, format uint32, xtype uint32, pixels interface{}) { + ptr, isPointer := pixels.(unsafe.Pointer) + if isPointer { + gl.TexImage2D(target, level, int32(internalFormat), width, height, border, format, xtype, ptr) + } else { + gl.TexImage2D(target, level, int32(internalFormat), width, height, border, format, xtype, gl.Ptr(pixels)) + } +} + +// TexParameteri implements the opengl.OpenGL interface. +func (native *OpenGL) TexParameteri(target uint32, pname uint32, param int32) { + gl.TexParameteri(target, pname, param) +} + +// Uniform1i implements the opengl.OpenGL interface. +func (native *OpenGL) Uniform1i(location int32, value int32) { + gl.Uniform1i(location, value) +} + +// Uniform4fv implements the opengl.OpenGL interface. +func (native *OpenGL) Uniform4fv(location int32, value *[4]float32) { + gl.Uniform4fv(location, 1, &value[0]) +} + +// UniformMatrix4fv implements the opengl.OpenGL interface. +func (native *OpenGL) UniformMatrix4fv(location int32, transpose bool, value *[16]float32) { + count := int32(1) + gl.UniformMatrix4fv(location, count, transpose, &value[0]) +} + +// UseProgram implements the opengl.OpenGL interface. +func (native *OpenGL) UseProgram(program uint32) { + gl.UseProgram(program) +} + +// VertexAttribOffset implements the opengl.OpenGL interface. +func (native *OpenGL) VertexAttribOffset(index uint32, size int32, attribType uint32, normalized bool, stride int32, offset int) { + gl.VertexAttribPointer(index, size, attribType, normalized, stride, gl.PtrOffset(offset)) +} + +// Viewport implements the opengl.OpenGL interface. +func (native *OpenGL) Viewport(x int32, y int32, width int32, height int32) { + gl.Viewport(x, y, width, height) +} diff --git a/modding/hacked/ui/native/OpenGlWindow.go b/modding/hacked/ui/native/OpenGlWindow.go new file mode 100644 index 0000000..d7e128e --- /dev/null +++ b/modding/hacked/ui/native/OpenGlWindow.go @@ -0,0 +1,288 @@ +package native + +import ( + "time" + + "github.com/go-gl/glfw/v3.2/glfw" + + "github.com/inkyblackness/hacked/ui/input" + "github.com/inkyblackness/hacked/ui/opengl" +) + +var buttonsByIndex = map[glfw.MouseButton]uint32{ + glfw.MouseButton1: input.MousePrimary, + glfw.MouseButton2: input.MouseSecondary} + +// OpenGLWindow represents a native OpenGL surface. +type OpenGLWindow struct { + opengl.WindowEventDispatcher + + keyBuffer *input.StickyKeyBuffer + + glfwWindow *glfw.Window + glWrapper *OpenGL + + titleBase string + titleSuffix string + projectModified bool + + framesPerSecond float64 + frameTime time.Duration + nextRenderTick time.Time +} + +// NewOpenGLWindow tries to initialize the OpenGL environment and returns a +// new window instance. +func NewOpenGLWindow(title string, framesPerSecond float64) (*OpenGLWindow, error) { + err := glfw.Init() + if err != nil { + return nil, err + } + + glfw.WindowHint(glfw.Resizable, 1) + glfw.WindowHint(glfw.Decorated, 1) + glfw.WindowHint(glfw.ClientAPI, glfw.OpenGLAPI) + glfw.WindowHint(glfw.ContextVersionMajor, 3) + glfw.WindowHint(glfw.ContextVersionMinor, 2) + glfw.WindowHint(glfw.OpenGLProfile, glfw.OpenGLCoreProfile) + glfw.WindowHint(glfw.OpenGLForwardCompatible, glfw.True) + glfw.WindowHint(glfw.DoubleBuffer, glfw.True) + glfwWindow, err := glfw.CreateWindow(1280, 720, title, nil, nil) + if err != nil { + return nil, err + } + glfwWindow.MakeContextCurrent() + glfw.SwapInterval(0) + + window := &OpenGLWindow{ + WindowEventDispatcher: opengl.NullWindowEventDispatcher(), + glfwWindow: glfwWindow, + glWrapper: NewOpenGL(), + titleBase: title, + framesPerSecond: framesPerSecond, + frameTime: time.Duration(int64(float64(time.Second) / framesPerSecond)), + nextRenderTick: time.Now()} + + window.keyBuffer = input.NewStickyKeyBuffer(window.StickyKeyListener()) + + glfwWindow.SetCursorPosCallback(window.onCursorPos) + glfwWindow.SetMouseButtonCallback(window.onMouseButton) + glfwWindow.SetScrollCallback(window.onMouseScroll) + glfwWindow.SetFramebufferSizeCallback(window.onFramebufferResize) + glfwWindow.SetKeyCallback(window.onKey) + glfwWindow.SetCharCallback(window.onChar) + glfwWindow.SetDropCallback(window.onDrop) + glfwWindow.SetCloseCallback(window.onClosing) + + return window, nil +} + +// ShouldClose returns true if the user requested the window to close. +func (window *OpenGLWindow) ShouldClose() bool { + return window.glfwWindow.ShouldClose() +} + +// Close closes the window and releases its resources. +func (window *OpenGLWindow) Close() { + window.CallClosed() + window.glfwWindow.Destroy() + glfw.Terminate() +} + +// StateSnapshot returns the current state of the window. +func (window OpenGLWindow) StateSnapshot() opengl.WindowState { + var state opengl.WindowState + if window.glfwWindow.GetAttrib(glfw.Maximized) != 0 { + state.Maximized = true + // restore window to retrieve restored details, otherwise the maximized details will be read. + _ = window.glfwWindow.Restore() + } + state.Left, state.Top = window.glfwWindow.GetPos() + state.Width, state.Height = window.glfwWindow.GetSize() + if state.Maximized { + _ = window.glfwWindow.Maximize() + } + return state +} + +// RestoreState puts the window into the same state when StateSnapshot() was called. +func (window OpenGLWindow) RestoreState(state opengl.WindowState) { + _ = window.glfwWindow.Restore() + window.glfwWindow.SetPos(state.Left, state.Top) + window.glfwWindow.SetSize(state.Width, state.Height) + if state.Maximized { + _ = window.glfwWindow.Maximize() + } +} + +// ClipboardString returns the current value of the clipboard, if it is compatible with UTF-8. +func (window OpenGLWindow) ClipboardString() (string, error) { + return window.glfwWindow.GetClipboardString() +} + +// SetClipboardString sets the current value of the clipboard as UTF-8 string. +func (window OpenGLWindow) SetClipboardString(value string) { + window.glfwWindow.SetClipboardString(value) +} + +// Update must be called from within the main thread as often as possible. +func (window *OpenGLWindow) Update() { + glfw.PollEvents() + + now := time.Now() + delta := now.Sub(window.nextRenderTick) + if delta.Nanoseconds() < 0 { + // detected a change of wallclock time into the past; realign + delta = window.frameTime + window.nextRenderTick = now + } + + if delta.Nanoseconds() >= window.frameTime.Nanoseconds() { + window.glfwWindow.MakeContextCurrent() + window.CallRender() + window.glfwWindow.SwapBuffers() + framesCovered := delta.Nanoseconds() / window.frameTime.Nanoseconds() + window.nextRenderTick = window.nextRenderTick.Add(time.Duration(framesCovered * window.frameTime.Nanoseconds())) + } +} + +// OpenGL returns the OpenGL API. +func (window *OpenGLWindow) OpenGL() opengl.OpenGL { + return window.glWrapper +} + +// Size returns the dimension of the frame buffer of this window. +func (window *OpenGLWindow) Size() (width int, height int) { + return window.glfwWindow.GetFramebufferSize() +} + +// SetCursorVisible toggles the visibility of the cursor. +func (window *OpenGLWindow) SetCursorVisible(visible bool) { + if visible { + window.glfwWindow.SetInputMode(glfw.CursorMode, glfw.CursorNormal) + } else { + window.glfwWindow.SetInputMode(glfw.CursorMode, glfw.CursorHidden) + } +} + +// SetFullScreen toggles the windowed mode. +func (window *OpenGLWindow) SetFullScreen(on bool) { + if on { + monitor := glfw.GetPrimaryMonitor() + videoMode := monitor.GetVideoMode() + window.glfwWindow.SetMonitor(monitor, 0, 0, videoMode.Width, videoMode.Height, glfw.DontCare) + } else { + window.glfwWindow.SetMonitor(nil, 0, 0, 1280, 720, glfw.DontCare) + } +} + +// SetTitleSuffix appends a text to the current window title. +func (window *OpenGLWindow) SetTitleSuffix(value string) { + window.titleSuffix = value + window.updateTitle() +} + +// SetProjectModified sets an indicator in the window frame that the project has not been saved. +func (window *OpenGLWindow) SetProjectModified(modified bool) { + window.projectModified = modified + window.updateTitle() +} + +func (window *OpenGLWindow) updateTitle() { + title := window.titleBase + if len(window.titleSuffix) > 0 { + title += " - " + window.titleSuffix + } + if window.projectModified { + title += " (unsaved changes)" + } + window.glfwWindow.SetTitle(title) +} + +// SetCloseRequest sets the should-close property of the window. +func (window *OpenGLWindow) SetCloseRequest(shouldClose bool) { + window.glfwWindow.SetShouldClose(shouldClose) +} + +func (window *OpenGLWindow) onClosing(rawWindow *glfw.Window) { + window.CallClosing() +} + +func (window *OpenGLWindow) onFramebufferResize(rawWindow *glfw.Window, width int, height int) { + window.CallResize(width, height) +} + +func (window *OpenGLWindow) onCursorPos(rawWindow *glfw.Window, x float64, y float64) { + window.CallOnMouseMove(float32(x), float32(y)) +} + +func (window *OpenGLWindow) onMouseButton(rawWindow *glfw.Window, rawButton glfw.MouseButton, action glfw.Action, mods glfw.ModifierKey) { + button, knownButton := buttonsByIndex[rawButton] + + if knownButton { + modifier := window.mapModifier(mods) + + if action == glfw.Press { + window.CallOnMouseButtonDown(button, modifier) + } else if action == glfw.Release { + window.CallOnMouseButtonUp(button, modifier) + } + } +} + +func (window *OpenGLWindow) onMouseScroll(rawWindow *glfw.Window, dx float64, dy float64) { + window.CallOnMouseScroll(float32(dx), float32(dy)) +} + +func (window *OpenGLWindow) onKey(rawWindow *glfw.Window, glfwKey glfw.Key, scancode int, action glfw.Action, mods glfw.ModifierKey) { + modifier := window.mapModifier(mods) + key, knownKey := keyMap[glfwKey] + if knownKey { + window.reportKey(action, key, modifier) + } + + keyName := glfw.GetKeyName(glfwKey, scancode) + key, knownKey = input.ResolveShortcut(keyName, modifier) + if knownKey { + window.reportKey(action, key, modifier) + } +} + +func (window *OpenGLWindow) reportKey(action glfw.Action, key input.Key, modifier input.Modifier) { + switch action { + case glfw.Press: + window.keyBuffer.KeyDown(key, modifier) + case glfw.Repeat: + window.keyBuffer.KeyUp(key, modifier) + window.keyBuffer.KeyDown(key, modifier) + case glfw.Release: + window.keyBuffer.KeyUp(key, modifier) + } +} + +func (window *OpenGLWindow) onChar(rawWindow *glfw.Window, char rune) { + window.CallCharCallback(char) +} + +func (window *OpenGLWindow) mapModifier(mods glfw.ModifierKey) input.Modifier { + modifier := input.ModNone + + if (mods & glfw.ModControl) != 0 { + modifier = modifier.With(input.ModControl) + } + if (mods & glfw.ModShift) != 0 { + modifier = modifier.With(input.ModShift) + } + if (mods & glfw.ModAlt) != 0 { + modifier = modifier.With(input.ModAlt) + } + if (mods & glfw.ModSuper) != 0 { + modifier = modifier.With(input.ModSuper) + } + + return modifier +} + +func (window *OpenGLWindow) onDrop(rawWindow *glfw.Window, filePaths []string) { + window.CallFileDropCallback(filePaths) +} diff --git a/modding/hacked/ui/native/Run.go b/modding/hacked/ui/native/Run.go new file mode 100644 index 0000000..506c6a1 --- /dev/null +++ b/modding/hacked/ui/native/Run.go @@ -0,0 +1,34 @@ +package native + +import ( + "runtime" + "time" + + "github.com/inkyblackness/hacked/ui/opengl" +) + +// Run creates a native OpenGL window, initializes it with the given function and +// then runs the event loop until the window shall be closed. +func Run(initializer func(opengl.Window) error, title string, framesPerSecond float64) error { + runtime.LockOSThread() + + window, err := NewOpenGLWindow(title, framesPerSecond) + if err != nil { + return err + } + defer window.Close() + + err = initializer(window) + if err != nil { + return err + } + + ticker := time.NewTicker(time.Millisecond) + for !window.ShouldClose() { + <-ticker.C + window.Update() + } + ticker.Stop() + + return nil +} diff --git a/modding/hacked/ui/opengl/CompileNewShader.go b/modding/hacked/ui/opengl/CompileNewShader.go new file mode 100644 index 0000000..86196e3 --- /dev/null +++ b/modding/hacked/ui/opengl/CompileNewShader.go @@ -0,0 +1,18 @@ +package opengl + +// CompileNewShader creates a shader of given type and compiles the provided source. +func CompileNewShader(gl OpenGL, shaderType uint32, source string) (shader uint32, err error) { + shader = gl.CreateShader(shaderType) + + gl.ShaderSource(shader, source) + gl.CompileShader(shader) + + compileStatus := gl.GetShaderParameter(shader, COMPILE_STATUS) + if compileStatus == 0 { + err = ShaderError{Log: gl.GetShaderInfoLog(shader)} + gl.DeleteShader(shader) + shader = 0 + } + + return +} diff --git a/modding/hacked/ui/opengl/DebugBuilder.go b/modding/hacked/ui/opengl/DebugBuilder.go new file mode 100644 index 0000000..9614db6 --- /dev/null +++ b/modding/hacked/ui/opengl/DebugBuilder.go @@ -0,0 +1,63 @@ +package opengl + +// DebuggingEntryFunc is a function to be called on function entry. +type DebuggingEntryFunc func(name string, param ...interface{}) + +// DebuggingExitFunc is a function to be called after an OpenGL function has returned. +// result may be several parameters for functions with multiple return values. +type DebuggingExitFunc func(name string, result ...interface{}) + +// DebuggingErrorFunc is a function which is called if a previous OpenGL call caused an error state. +type DebuggingErrorFunc func(name string, errorCodes []uint32) + +// DebugBuilder is a builder for an OpenGL implementation for debugging. +type DebugBuilder struct { + wrapped OpenGL + + onEntry DebuggingEntryFunc + onExit DebuggingExitFunc + onError DebuggingErrorFunc +} + +// NewDebugBuilder wraps the provided OpenGL instance and returns a new builder +// instance. +func NewDebugBuilder(wrapped OpenGL) *DebugBuilder { + builder := &DebugBuilder{ + wrapped: wrapped, + onEntry: func(string, ...interface{}) {}, + onExit: func(string, ...interface{}) {}, + onError: func(string, []uint32) {}} + + return builder +} + +// Build creates a new instance of the debugging OpenGL implementation. +// The builder can be reused to create another instance with different parameters. +func (builder *DebugBuilder) Build() OpenGL { + opengl := &debuggingOpenGL{ + gl: builder.wrapped, + + onEntry: builder.onEntry, + onExit: builder.onExit, + onError: builder.onError} + + return opengl +} + +// OnEntry registers a callback function to be called before an OpenGL function is called. +func (builder *DebugBuilder) OnEntry(callback DebuggingEntryFunc) *DebugBuilder { + builder.onEntry = callback + return builder +} + +// OnExit registers a callback function to be called after an OpenGL function returned. +func (builder *DebugBuilder) OnExit(callback DebuggingExitFunc) *DebugBuilder { + builder.onExit = callback + return builder +} + +// OnError registers a callback function to be called when an OpenGL function set an error state. +func (builder *DebugBuilder) OnError(callback DebuggingErrorFunc) *DebugBuilder { + builder.onError = callback + return builder +} diff --git a/modding/hacked/ui/opengl/DebuggingOpenGl.go b/modding/hacked/ui/opengl/DebuggingOpenGl.go new file mode 100644 index 0000000..55d1554 --- /dev/null +++ b/modding/hacked/ui/opengl/DebuggingOpenGl.go @@ -0,0 +1,490 @@ +package opengl + +type debuggingOpenGL struct { + gl OpenGL + + onEntry DebuggingEntryFunc + onExit DebuggingExitFunc + onError DebuggingErrorFunc +} + +func (debugging *debuggingOpenGL) recordEntry(name string, param ...interface{}) { + debugging.onEntry(name, param...) +} + +func (debugging *debuggingOpenGL) recordExit(name string, result ...interface{}) { + var errorCodes []uint32 + + for errorCode := debugging.gl.GetError(); errorCode != NO_ERROR; errorCode = debugging.gl.GetError() { + errorCodes = append(errorCodes, errorCode) + } + debugging.onExit(name, result...) + if len(errorCodes) > 0 { + debugging.onError(name, errorCodes) + } +} + +// ActiveTexture implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) ActiveTexture(texture uint32) { + debugging.recordEntry("ActiveTexture", texture) + debugging.gl.ActiveTexture(texture) + debugging.recordExit("ActiveTexture") +} + +// AttachShader implements the OpenGL interface. +func (debugging *debuggingOpenGL) AttachShader(program uint32, shader uint32) { + debugging.recordEntry("AttachShader", program, shader) + debugging.gl.AttachShader(program, shader) + debugging.recordExit("AttachShader") +} + +// BindAttribLocation implements the OpenGL interface. +func (debugging *debuggingOpenGL) BindAttribLocation(program uint32, index uint32, name string) { + debugging.recordEntry("BindAttribLocation", program, index, name) + debugging.gl.BindAttribLocation(program, index, name) + debugging.recordExit("BindAttribLocation") +} + +// BindBuffer implements the OpenGL interface. +func (debugging *debuggingOpenGL) BindBuffer(target uint32, buffer uint32) { + debugging.recordEntry("BindBuffer", target, buffer) + debugging.gl.BindBuffer(target, buffer) + debugging.recordExit("BindBuffer") +} + +// BindFramebuffer implements the OpenGL interface. +func (debugging *debuggingOpenGL) BindFramebuffer(target uint32, buffer uint32) { + debugging.recordEntry("BindFramebuffer", target, buffer) + debugging.gl.BindFramebuffer(target, buffer) + debugging.recordExit("BindFramebuffer") +} + +// BindRenderbuffer implements the OpenGL interface. +func (debugging *debuggingOpenGL) BindRenderbuffer(target uint32, buffer uint32) { + debugging.recordEntry("BindRenderbuffer", target, buffer) + debugging.gl.BindRenderbuffer(target, buffer) + debugging.recordExit("BindRenderbuffer") +} + +// BindSampler implements the OpenGL interface. +func (debugging *debuggingOpenGL) BindSampler(unit uint32, sampler uint32) { + debugging.recordEntry("BindSampler", unit, sampler) + debugging.gl.BindSampler(unit, sampler) + debugging.recordExit("BindSampler") +} + +// BindTexture implements the OpenGL interface. +func (debugging *debuggingOpenGL) BindTexture(target uint32, texture uint32) { + debugging.recordEntry("BindTexture", target, texture) + debugging.gl.BindTexture(target, texture) + debugging.recordExit("BindTexture") +} + +// BindVertexArray implements the OpenGL interface. +func (debugging *debuggingOpenGL) BindVertexArray(array uint32) { + debugging.recordEntry("BindVertexArray", array) + debugging.gl.BindVertexArray(array) + debugging.recordExit("BindVertexArray") +} + +// BlendEquation implements the OpenGL interface. +func (debugging *debuggingOpenGL) BlendEquation(mode uint32) { + debugging.recordEntry("BlendEquation", mode) + debugging.gl.BlendEquation(mode) + debugging.recordExit("BlendEquation") +} + +// BlendEquationSeparate implements the OpenGL interface. +func (debugging *debuggingOpenGL) BlendEquationSeparate(modeRGB uint32, modeAlpha uint32) { + debugging.recordEntry("BlendEquationSeparate", modeRGB, modeAlpha) + debugging.gl.BlendEquationSeparate(modeRGB, modeAlpha) + debugging.recordExit("BlendEquationSeparate") +} + +// BlendFunc implements the OpenGL interface. +func (debugging *debuggingOpenGL) BlendFunc(sfactor uint32, dfactor uint32) { + debugging.recordEntry("BlendFunc", sfactor, dfactor) + debugging.gl.BlendFunc(sfactor, dfactor) + debugging.recordExit("BlendFunc") +} + +// BlendFuncSeparate implements the OpenGL interface. +func (debugging *debuggingOpenGL) BlendFuncSeparate(srcRGB uint32, dstRGB uint32, srcAlpha uint32, dstAlpha uint32) { + debugging.recordEntry("BlendFuncSeparate", srcRGB, dstRGB, srcAlpha, dstAlpha) + debugging.gl.BlendFuncSeparate(srcRGB, dstRGB, srcAlpha, dstAlpha) + debugging.recordExit("BlendFuncSeparate") +} + +// BufferData implements the OpenGL interface. +func (debugging *debuggingOpenGL) BufferData(target uint32, size int, data interface{}, usage uint32) { + debugging.recordEntry("BufferData", target, size, data, usage) + debugging.gl.BufferData(target, size, data, usage) + debugging.recordExit("BufferData") +} + +// CheckFramebufferStatus implements the OpenGL interface. +func (debugging *debuggingOpenGL) CheckFramebufferStatus(target uint32) uint32 { + debugging.recordEntry("CheckFramebufferStatus", target) + result := debugging.gl.CheckFramebufferStatus(target) + debugging.recordExit("CheckFramebufferStatus") + return result +} + +// Clear implements the OpenGL interface. +func (debugging *debuggingOpenGL) Clear(mask uint32) { + debugging.recordEntry("Clear", mask) + debugging.gl.Clear(mask) + debugging.recordExit("Clear") +} + +// ClearColor implements the OpenGL interface. +func (debugging *debuggingOpenGL) ClearColor(red float32, green float32, blue float32, alpha float32) { + debugging.recordEntry("ClearColor", red, green, blue, alpha) + debugging.gl.ClearColor(red, green, blue, alpha) + debugging.recordExit("ClearColor") +} + +// CompileShader implements the OpenGL interface. +func (debugging *debuggingOpenGL) CompileShader(shader uint32) { + debugging.recordEntry("CompileShader", shader) + debugging.gl.CompileShader(shader) + debugging.recordExit("CompileShader") +} + +// CreateProgram implements the OpenGL interface. +func (debugging *debuggingOpenGL) CreateProgram() uint32 { + debugging.recordEntry("CreateProgram") + result := debugging.gl.CreateProgram() + debugging.recordExit("CreateProgram", result) + return result +} + +// CreateShader implements the OpenGL interface. +func (debugging *debuggingOpenGL) CreateShader(shaderType uint32) uint32 { + debugging.recordEntry("CreateShader", shaderType) + result := debugging.gl.CreateShader(shaderType) + debugging.recordExit("CreateShader", result) + return result +} + +// DeleteBuffers implements the OpenGL interface. +func (debugging *debuggingOpenGL) DeleteBuffers(buffers []uint32) { + debugging.recordEntry("DeleteBuffers", buffers) + debugging.gl.DeleteBuffers(buffers) + debugging.recordExit("DeleteBuffers") +} + +// DeleteFramebuffers implements the OpenGL interface. +func (debugging *debuggingOpenGL) DeleteFramebuffers(buffers []uint32) { + debugging.recordEntry("DeleteFramebuffers", buffers) + debugging.gl.DeleteFramebuffers(buffers) + debugging.recordExit("DeleteFramebuffers") +} + +// DeleteProgram implements the OpenGL interface. +func (debugging *debuggingOpenGL) DeleteProgram(program uint32) { + debugging.recordEntry("DeleteProgram", program) + debugging.gl.DeleteProgram(program) + debugging.recordExit("DeleteProgram") +} + +// DeleteShader implements the OpenGL interface. +func (debugging *debuggingOpenGL) DeleteShader(shader uint32) { + debugging.recordEntry("DeleteShader", shader) + debugging.gl.DeleteShader(shader) + debugging.recordExit("DeleteShader") +} + +// DeleteTextures implements the OpenGL interface. +func (debugging *debuggingOpenGL) DeleteTextures(textures []uint32) { + debugging.recordEntry("DeleteTextures", textures) + debugging.gl.DeleteTextures(textures) + debugging.recordExit("DeleteTextures") +} + +// DeleteVertexArrays implements the OpenGL interface. +func (debugging *debuggingOpenGL) DeleteVertexArrays(arrays []uint32) { + debugging.recordEntry("DeleteVertexArrays", arrays) + debugging.gl.DeleteVertexArrays(arrays) + debugging.recordExit("DeleteVertexArrays") +} + +// Disable implements the OpenGL interface. +func (debugging *debuggingOpenGL) Disable(capability uint32) { + debugging.recordEntry("Disable", capability) + debugging.gl.Disable(capability) + debugging.recordExit("Disable") +} + +// DrawArrays implements the OpenGL interface. +func (debugging *debuggingOpenGL) DrawArrays(mode uint32, first int32, count int32) { + debugging.recordEntry("DrawArrays", first, count) + debugging.gl.DrawArrays(mode, first, count) + debugging.recordExit("DrawArrays") +} + +// DrawBuffers implements the OpenGL interface. +func (debugging *debuggingOpenGL) DrawBuffers(buffers []uint32) { + debugging.recordEntry("DrawBuffers", buffers) + debugging.gl.DrawBuffers(buffers) + debugging.recordExit("DrawBuffers") +} + +// DrawElements implements the OpenGL interface. +func (debugging *debuggingOpenGL) DrawElements(mode uint32, count int32, elementType uint32, indices uintptr) { + debugging.recordEntry("DrawElements", mode, count, elementType, indices) + debugging.gl.DrawElements(mode, count, elementType, indices) + debugging.recordExit("DrawElements") +} + +// Enable implements the OpenGL interface. +func (debugging *debuggingOpenGL) Enable(capability uint32) { + debugging.recordEntry("Enable", capability) + debugging.gl.Enable(capability) + debugging.recordExit("Enable") +} + +// EnableVertexAttribArray implements the OpenGL interface. +func (debugging *debuggingOpenGL) EnableVertexAttribArray(index uint32) { + debugging.recordEntry("EnableVertexAttribArray", index) + debugging.gl.EnableVertexAttribArray(index) + debugging.recordExit("EnableVertexAttribArray") +} + +// FramebufferRenderbuffer implements the OpenGL interface. +func (debugging *debuggingOpenGL) FramebufferRenderbuffer(target uint32, attachment uint32, renderbuffertarget uint32, renderbuffer uint32) { + debugging.recordEntry("FramebufferRenderbuffer", target, attachment, renderbuffertarget, renderbuffer) + debugging.gl.FramebufferRenderbuffer(target, attachment, renderbuffertarget, renderbuffer) + debugging.recordExit("FramebufferRenderbuffer") +} + +// FramebufferTexture implements the OpenGL interface. +func (debugging *debuggingOpenGL) FramebufferTexture(target uint32, attachment uint32, texture uint32, level int32) { + debugging.recordEntry("FramebufferTexture", target, attachment, texture, level) + debugging.gl.FramebufferTexture(target, attachment, texture, level) + debugging.recordExit("FramebufferTexture") +} + +// GenerateMipmap implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) GenerateMipmap(target uint32) { + debugging.recordEntry("GenerateMipmap", target) + debugging.gl.GenerateMipmap(target) + debugging.recordExit("GenerateMipmap") +} + +// GenBuffers implements the OpenGL interface. +func (debugging *debuggingOpenGL) GenBuffers(n int32) []uint32 { + debugging.recordEntry("GenBuffers", n) + result := debugging.gl.GenBuffers(n) + debugging.recordExit("GenBuffers", result) + return result +} + +// GenFramebuffers implements the OpenGL interface. +func (debugging *debuggingOpenGL) GenFramebuffers(n int32) []uint32 { + debugging.recordEntry("GenFramebuffers", n) + result := debugging.gl.GenFramebuffers(n) + debugging.recordExit("GenFramebuffers", result) + return result +} + +// GenRenderbuffers implements the OpenGL interface. +func (debugging *debuggingOpenGL) GenRenderbuffers(n int32) []uint32 { + debugging.recordEntry("GenRenderbuffers", n) + result := debugging.gl.GenRenderbuffers(n) + debugging.recordExit("GenRenderbuffers", result) + return result +} + +// GenTextures implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) GenTextures(n int32) []uint32 { + debugging.recordEntry("GenTextures", n) + result := debugging.gl.GenTextures(n) + debugging.recordExit("GenTextures", result) + return result +} + +// GenVertexArrays implements the OpenGL interface. +func (debugging *debuggingOpenGL) GenVertexArrays(n int32) []uint32 { + debugging.recordEntry("GenVertexArrays", n) + result := debugging.gl.GenVertexArrays(n) + debugging.recordExit("GenVertexArrays", result) + return result +} + +// GetAttribLocation implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetAttribLocation(program uint32, name string) int32 { + debugging.recordEntry("GetAttribLocation", program, name) + result := debugging.gl.GetAttribLocation(program, name) + debugging.recordExit("GetAttribLocation", result) + return result +} + +// GetError implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetError() uint32 { + debugging.recordEntry("GetError") + result := debugging.gl.GetError() + debugging.recordExit("GetError", result) + return result +} + +// GetIntegerv implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetIntegerv(name uint32, data *int32) { + debugging.recordEntry("GetIntegerv", name, data) + debugging.gl.GetIntegerv(name, data) + debugging.recordExit("GetIntegerv") +} + +// GetProgramInfoLog implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetProgramInfoLog(program uint32) string { + debugging.recordEntry("GetProgramInfoLog", program) + result := debugging.gl.GetProgramInfoLog(program) + debugging.recordExit("GetProgramInfoLog", result) + return result +} + +// GetProgramParameter implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetProgramParameter(program uint32, param uint32) int32 { + debugging.recordEntry("GetProgramParameter", program, param) + result := debugging.gl.GetProgramParameter(program, param) + debugging.recordExit("GetProgramParameter", result) + return result +} + +// GetShaderInfoLog implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetShaderInfoLog(shader uint32) string { + debugging.recordEntry("GetShaderInfoLog", shader) + result := debugging.gl.GetShaderInfoLog(shader) + debugging.recordExit("GetShaderInfoLog", result) + return result +} + +// GetShaderParameter implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetShaderParameter(shader uint32, param uint32) int32 { + debugging.recordEntry("GetShaderParameter", shader, param) + result := debugging.gl.GetShaderParameter(shader, param) + debugging.recordExit("GetShaderParameter", result) + return result +} + +// GetUniformLocation implements the OpenGL interface. +func (debugging *debuggingOpenGL) GetUniformLocation(program uint32, name string) int32 { + debugging.recordEntry("GetUniformLocation", program, name) + result := debugging.gl.GetUniformLocation(program, name) + debugging.recordExit("GetUniformLocation", result) + return result +} + +// IsEnabled implements the OpenGL interface. +func (debugging *debuggingOpenGL) IsEnabled(capability uint32) bool { + debugging.recordEntry("IsEnabled", capability) + result := debugging.gl.IsEnabled(capability) + debugging.recordExit("IsEnabled", result) + return result +} + +// LinkProgram implements the OpenGL interface. +func (debugging *debuggingOpenGL) LinkProgram(program uint32) { + debugging.recordEntry("LinkProgram", program) + debugging.gl.LinkProgram(program) + debugging.recordExit("LinkProgram") +} + +// PixelStorei implements the OpenGL interface. +func (debugging *debuggingOpenGL) PixelStorei(name uint32, param int32) { + debugging.recordEntry("PixelStorei", name, param) + debugging.gl.PixelStorei(name, param) + debugging.recordExit("PixelStorei") +} + +// PolygonMode implements the OpenGL interface. +func (debugging *debuggingOpenGL) PolygonMode(face uint32, mode uint32) { + debugging.recordEntry("PolygonMode", face, mode) + debugging.gl.PolygonMode(face, mode) + debugging.recordExit("PolygonMode") +} + +// ReadPixels implements the OpenGL interface. +func (debugging *debuggingOpenGL) ReadPixels(x int32, y int32, width int32, height int32, format uint32, pixelType uint32, pixels interface{}) { + debugging.recordEntry("ReadPixels", x, y, width, height, format, pixelType, pixels) + debugging.gl.ReadPixels(x, y, width, height, format, pixelType, pixels) + debugging.recordExit("ReadPixels") +} + +// RenderbufferStorage implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) RenderbufferStorage(target uint32, internalFormat uint32, width int32, height int32) { + debugging.recordEntry("RenderbufferStorage", target, internalFormat, width, height) + debugging.gl.RenderbufferStorage(target, internalFormat, width, height) + debugging.recordExit("RenderbufferStorage") +} + +// Scissor implements the OpenGL interface. +func (debugging *debuggingOpenGL) Scissor(x, y int32, width, height int32) { + debugging.recordEntry("Scissor", x, y, width, height) + debugging.gl.Scissor(x, y, width, height) + debugging.recordExit("Scissor") +} + +// ShaderSource implements the OpenGL interface. +func (debugging *debuggingOpenGL) ShaderSource(shader uint32, source string) { + debugging.recordEntry("ShaderSource", shader, source) + debugging.gl.ShaderSource(shader, source) + debugging.recordExit("ShaderSource") +} + +// TexImage2D implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) TexImage2D(target uint32, level int32, internalFormat uint32, width int32, height int32, + border int32, format uint32, xtype uint32, pixels interface{}) { + debugging.recordEntry("TexImage2D", target, level, internalFormat, width, height, border, format, xtype, pixels) + debugging.gl.TexImage2D(target, level, internalFormat, width, height, border, format, xtype, pixels) + debugging.recordExit("TexImage2D") +} + +// TexParameteri implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) TexParameteri(target uint32, pname uint32, param int32) { + debugging.recordEntry("TexParameteri", target, pname, param) + debugging.gl.TexParameteri(target, pname, param) + debugging.recordExit("TexParameteri") +} + +// Uniform1i implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) Uniform1i(location int32, value int32) { + debugging.recordEntry("Uniform1i", location, value) + debugging.gl.Uniform1i(location, value) + debugging.recordExit("Uniform1i") +} + +// Uniform4fv implements the opengl.OpenGL interface. +func (debugging *debuggingOpenGL) Uniform4fv(location int32, value *[4]float32) { + debugging.recordEntry("Uniform4fv", location, value) + debugging.gl.Uniform4fv(location, value) + debugging.recordExit("Uniform4fv") +} + +// UniformMatrix4fv implements the OpenGL interface. +func (debugging *debuggingOpenGL) UniformMatrix4fv(location int32, transpose bool, value *[16]float32) { + debugging.recordEntry("UniformMatrix4fv", location, transpose, value) + debugging.gl.UniformMatrix4fv(location, transpose, value) + debugging.recordExit("UniformMatrix4fv") +} + +// UseProgram implements the OpenGL interface. +func (debugging *debuggingOpenGL) UseProgram(program uint32) { + debugging.recordEntry("UseProgram", program) + debugging.gl.UseProgram(program) + debugging.recordExit("UseProgram") +} + +// VertexAttribOffset implements the OpenGL interface. +func (debugging *debuggingOpenGL) VertexAttribOffset(index uint32, size int32, attribType uint32, normalized bool, stride int32, offset int) { + debugging.recordEntry("VertexAttribOffset", index, size, attribType, normalized, stride, offset) + debugging.gl.VertexAttribOffset(index, size, attribType, normalized, stride, offset) + debugging.recordExit("VertexAttribOffset") +} + +// Viewport implements the OpenGL interface. +func (debugging *debuggingOpenGL) Viewport(x int32, y int32, width int32, height int32) { + debugging.recordEntry("Viewport", x, y, width, height) + debugging.gl.Viewport(x, y, width, height) + debugging.recordExit("Viewport") +} diff --git a/modding/hacked/ui/opengl/Errors.go b/modding/hacked/ui/opengl/Errors.go new file mode 100644 index 0000000..813a4d2 --- /dev/null +++ b/modding/hacked/ui/opengl/Errors.go @@ -0,0 +1,54 @@ +package opengl + +import ( + "fmt" +) + +var errorStrings = map[uint32]string{ + NO_ERROR: "NO_ERROR", + INVALID_ENUM: "INVALID_ENUM", + INVALID_VALUE: "INVALID_VALUE", + INVALID_OPERATION: "INVALID_OPERATION", + STACK_OVERFLOW: "STACK_OVERFLOW", + STACK_UNDERFLOW: "STACK_UNDERFLOW", + OUT_OF_MEMORY: "OUT_OF_MEMORY", + INVALID_FRAMEBUFFER_OPERATION: "INVALID_FRAMEBUFFER_OPERATION"} + +// ErrorString returns a readable version of the provided error code. If the +// code is unknown, the textual representation of the hexadecimal value is +// returned. +func ErrorString(errorCode uint32) string { + value, exists := errorStrings[errorCode] + + if !exists { + value = fmt.Sprintf("0x%04X", errorCode) + } + + return value +} + +// ShaderError describes a problem with a log. +type ShaderError struct { + Log string +} + +// Error returns the log. +func (err ShaderError) Error() string { + return err.Log +} + +// NamedShaderError is an error with a name. +type NamedShaderError struct { + Name string + Nested error +} + +// Error returns the name with the nested error information. +func (err NamedShaderError) Error() string { + return fmt.Sprintf("%s failed: %v", err.Name, err.Nested) +} + +// Unwrap returns the nested error. +func (err NamedShaderError) Unwrap() error { + return err.Nested +} diff --git a/modding/hacked/ui/opengl/OpenGl.go b/modding/hacked/ui/opengl/OpenGl.go new file mode 100644 index 0000000..ccb6dca --- /dev/null +++ b/modding/hacked/ui/opengl/OpenGl.go @@ -0,0 +1,90 @@ +package opengl + +// OpenGL describes an Open GL interface usable for all environments of this +// application. +type OpenGL interface { + ActiveTexture(texture uint32) + AttachShader(program uint32, shader uint32) + + BindAttribLocation(program uint32, index uint32, name string) + BindBuffer(target uint32, buffer uint32) + BindFramebuffer(target uint32, buffer uint32) + BindRenderbuffer(target uint32, buffer uint32) + BindSampler(unit uint32, sampler uint32) + BindTexture(target uint32, texture uint32) + BindVertexArray(array uint32) + BlendEquation(mode uint32) + BlendEquationSeparate(modeRGB uint32, modeAlpha uint32) + BlendFunc(sfactor uint32, dfactor uint32) + BlendFuncSeparate(srcRGB uint32, dstRGB uint32, srcAlpha uint32, dstAlpha uint32) + BufferData(target uint32, size int, data interface{}, usage uint32) + + CheckFramebufferStatus(target uint32) uint32 + + Clear(mask uint32) + ClearColor(red float32, green float32, blue float32, alpha float32) + + CompileShader(shader uint32) + + CreateProgram() uint32 + CreateShader(shaderType uint32) uint32 + + DeleteBuffers(buffers []uint32) + DeleteFramebuffers(buffers []uint32) + DeleteProgram(program uint32) + DeleteShader(shader uint32) + DeleteTextures(textures []uint32) + DeleteVertexArrays(arrays []uint32) + Disable(capability uint32) + + DrawArrays(mode uint32, first int32, count int32) + DrawBuffers(buffers []uint32) + DrawElements(mode uint32, count int32, elementType uint32, indices uintptr) + + Enable(capability uint32) + EnableVertexAttribArray(index uint32) + + FramebufferRenderbuffer(target uint32, attachment uint32, renderbuffertarget uint32, renderbuffer uint32) + FramebufferTexture(target uint32, attachment uint32, texture uint32, level int32) + + GenerateMipmap(target uint32) + GenBuffers(n int32) []uint32 + GenFramebuffers(n int32) []uint32 + GenRenderbuffers(n int32) []uint32 + GenTextures(n int32) []uint32 + GenVertexArrays(n int32) []uint32 + + GetAttribLocation(program uint32, name string) int32 + GetError() uint32 + GetIntegerv(name uint32, data *int32) + GetShaderInfoLog(shader uint32) string + GetShaderParameter(shader uint32, param uint32) int32 + GetProgramInfoLog(program uint32) string + GetProgramParameter(program uint32, param uint32) int32 + GetUniformLocation(program uint32, name string) int32 + + IsEnabled(capability uint32) bool + + LinkProgram(program uint32) + + PixelStorei(name uint32, param int32) + PolygonMode(face uint32, mode uint32) + + ReadPixels(x int32, y int32, width int32, height int32, format uint32, pixelType uint32, pixels interface{}) + RenderbufferStorage(target uint32, internalFormat uint32, width int32, height int32) + + Scissor(x, y int32, width, height int32) + ShaderSource(shader uint32, source string) + + TexImage2D(target uint32, level int32, internalFormat uint32, width int32, height int32, + border int32, format uint32, xtype uint32, pixels interface{}) + TexParameteri(target uint32, pname uint32, param int32) + + Uniform1i(location int32, value int32) + Uniform4fv(location int32, value *[4]float32) + UniformMatrix4fv(location int32, transpose bool, value *[16]float32) + UseProgram(program uint32) + + VertexAttribOffset(index uint32, size int32, attribType uint32, normalized bool, stride int32, offset int) + Viewport(x int32, y int32, width int32, height int32) +} diff --git a/modding/hacked/ui/opengl/Program.go b/modding/hacked/ui/opengl/Program.go new file mode 100644 index 0000000..939c71d --- /dev/null +++ b/modding/hacked/ui/opengl/Program.go @@ -0,0 +1,35 @@ +package opengl + +// LinkNewProgram creates a new shader program based on the provided shaders. +func LinkNewProgram(gl OpenGL, shaders ...uint32) (program uint32, err error) { + program = gl.CreateProgram() + + for _, shader := range shaders { + gl.AttachShader(program, shader) + } + gl.LinkProgram(program) + + if gl.GetProgramParameter(program, LINK_STATUS) == 0 { + err = ShaderError{Log: gl.GetProgramInfoLog(program)} + gl.DeleteProgram(program) + program = 0 + } + + return +} + +// LinkNewStandardProgram creates a new shader based on two shader sources. +func LinkNewStandardProgram(gl OpenGL, vertexShaderSource, fragmentShaderSource string) (program uint32, err error) { + vertexShader, vertexErr := CompileNewShader(gl, VERTEX_SHADER, vertexShaderSource) + if vertexErr != nil { + return 0, NamedShaderError{Name: "vertexShader", Nested: vertexErr} + } + defer gl.DeleteShader(vertexShader) + fragmentShader, fragmentErr := CompileNewShader(gl, FRAGMENT_SHADER, fragmentShaderSource) + if fragmentErr != nil { + return 0, NamedShaderError{Name: "fragmentShader", Nested: fragmentErr} + } + defer gl.DeleteShader(fragmentShader) + + return LinkNewProgram(gl, vertexShader, fragmentShader) +} diff --git a/modding/hacked/ui/opengl/Uniforms.go b/modding/hacked/ui/opengl/Uniforms.go new file mode 100644 index 0000000..a50e3bf --- /dev/null +++ b/modding/hacked/ui/opengl/Uniforms.go @@ -0,0 +1,25 @@ +package opengl + +import ( + mgl "github.com/go-gl/mathgl/mgl32" +) + +// UniformLocation represents a uniform parameter for a shader program. +type UniformLocation int32 + +// Matrix4Uniform represents one uniform parameter for a 4x4 matrix. +type Matrix4Uniform UniformLocation + +// Set stores the provided matrix as the current uniform value. +func (location Matrix4Uniform) Set(gl OpenGL, matrix *mgl.Mat4) { + matrixArray := (*[16]float32)(matrix) + gl.UniformMatrix4fv(int32(location), false, matrixArray) +} + +// Vector4Uniform represents one uniform parameter for a 4-dimensional vector. +type Vector4Uniform UniformLocation + +// Set stores the provided 4-dimensional vector as the current uniform value. +func (location Vector4Uniform) Set(gl OpenGL, value *[4]float32) { + gl.Uniform4fv(int32(location), value) +} diff --git a/modding/hacked/ui/opengl/VertexArrayObject.go b/modding/hacked/ui/opengl/VertexArrayObject.go new file mode 100644 index 0000000..c977eea --- /dev/null +++ b/modding/hacked/ui/opengl/VertexArrayObject.go @@ -0,0 +1,62 @@ +package opengl + +// AttributeSetter is a callback function for a VertexArrayObject. +type AttributeSetter func(OpenGL) + +// VertexArrayObject registers common vertex properties which are pre-set +// before regular rendering loops. +// Depending on the supported level of the Open GL API, these settings are +// either buffered (using a VAO - vertex array object), or replayed for +// every render call. +type VertexArrayObject struct { + gl OpenGL + program uint32 + handle uint32 + setter AttributeSetter +} + +// NewVertexArrayObject returns a new instance. +func NewVertexArrayObject(gl OpenGL, program uint32) *VertexArrayObject { + vao := &VertexArrayObject{ + gl: gl, + program: program, + handle: gl.GenVertexArrays(1)[0], + setter: func(OpenGL) {}} + + return vao +} + +// Dispose drops any associated resources. +func (vao *VertexArrayObject) Dispose() { + vao.gl.DeleteVertexArrays([]uint32{vao.handle}) + vao.handle = 0 +} + +// WithSetter registers the setter function for this object. +// Depending on the support of the OpenGL API, this setter may be called +// once immediately, or for each render call. +func (vao *VertexArrayObject) WithSetter(setter AttributeSetter) { + if vao.handle != 0 { + vao.OnShader(func() { setter(vao.gl) }) + } + vao.setter = setter +} + +// OnShader executes the provided task with an activated shader program +// and the vertex array object initialized. +func (vao *VertexArrayObject) OnShader(task func()) { + gl := vao.gl + + gl.UseProgram(vao.program) + gl.BindVertexArray(vao.handle) + + defer func() { + gl.BindVertexArray(0) + gl.UseProgram(0) + }() + + if vao.handle == 0 { + vao.setter(gl) + } + task() +} diff --git a/modding/hacked/ui/opengl/Window.go b/modding/hacked/ui/opengl/Window.go new file mode 100644 index 0000000..af91353 --- /dev/null +++ b/modding/hacked/ui/opengl/Window.go @@ -0,0 +1,147 @@ +package opengl + +import ( + "encoding/json" + "io/ioutil" + + "github.com/inkyblackness/hacked/ui/input" +) + +// ClosingCallback is the function to handle close requests by the user. +type ClosingCallback func() + +// ClosedCallback is the function to clean up resources when the window is being closed. +type ClosedCallback func() + +// RenderCallback is the function to receive render events. When the callback +// returns, the window will swap the internal buffer. +type RenderCallback func() + +// MouseMoveCallback is the function to receive the current mouse coordinate while moving. +// Movement is reported while the cursor is within the client area of the window, and +// beyond the window as long as at least one captured button is pressed. +// Reported values are with sub-pixel precision, if possible. +type MouseMoveCallback func(x float32, y float32) + +// MouseButtonCallback is the function to receive button up/down events. +// An Up event is sent for every reported Down event, even if the mouse cursor is outside +// the client area. +type MouseButtonCallback func(buttonMask uint32, modifier input.Modifier) + +// MouseScrollCallback is the function to receive scroll events. +// Delta values are right-hand oriented: positive values go right/down/far. +type MouseScrollCallback func(dx float32, dy float32) + +// ResizeCallback is called for a change of window dimensions. +type ResizeCallback func(width int, height int) + +// CharCallback is called for typing a character. +type CharCallback func(char rune) + +// KeyCallback is called for pressing or releasing a key on the keyboard. +type KeyCallback func(key input.Key, modifier input.Modifier) + +// ModifierCallback is called when the currently active modifier changed. +type ModifierCallback func(modifier input.Modifier) + +// FileDropCallback is called when one or more files were dropped into the window. +type FileDropCallback func(filePaths []string) + +// WindowState describes how the window is currently presented to the user. +type WindowState struct { + // Maximized is set to true if the window is currently resized to fill the screen. + Maximized bool + // Top marks the screen position of the upper border of the window. + Top int + // Left marks the screen position of the left border of the window. + Left int + // Width marks the width of the non-maximized window. + Width int + // Height marks the height of the non-maximized window. + Height int +} + +// WindowStateFromFile attempts to load the state from a file with given name. +// If this does not work, the given default state will be returned. +func WindowStateFromFile(filename string, defaultState WindowState) WindowState { + data, err := ioutil.ReadFile(filename) + if err != nil { + return defaultState + } + var state WindowState + err = json.Unmarshal(data, &state) + if err != nil { + return defaultState + } + return state +} + +// SaveTo stores the state in a file with given filename. +func (state WindowState) SaveTo(filename string) error { + bytes, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return ioutil.WriteFile(filename, bytes, 0640) +} + +// Window represents an OpenGL render surface. +type Window interface { + // StateSnapshot returns the current state of the window. + StateSnapshot() WindowState + // RestoreState puts the window into the same state when StateSnapshot() was called. + RestoreState(state WindowState) + + // ClipboardString returns the current value of the clipboard, if it is compatible with UTF-8. + ClipboardString() (string, error) + // SetClipboardString sets the current value of the clipboard as UTF-8 string. + SetClipboardString(value string) + + // OnClosing registers a callback function which shall be called when the user requests to close the window. + OnClosing(callback ClosingCallback) + // OnClosed registers a callback function which shall be called when the window is being closed. + OnClosed(callback ClosedCallback) + // SetCloseRequest notifies the window system whether the window shall be closed. + // It can be called during the callback of OnClosing() to abort a close request by the user. + SetCloseRequest(shouldClose bool) + + // OpenGL returns the OpenGL API wrapper for this window. + OpenGL() OpenGL + // OnRender registers a callback function which shall be called to update the scene. + OnRender(callback RenderCallback) + + // OnResize registers a callback function for sizing events. + OnResize(callback ResizeCallback) + // Size returns the dimensions of the window display area in pixel. + Size() (width int, height int) + // SetFullScreen sets the full screen state of the window. + SetFullScreen(on bool) + // SetTitleSuffix appends a text to the current window title. + SetTitleSuffix(value string) + // SetProjectModified sets an indicator in the window frame that the project has not been saved. + SetProjectModified(modified bool) + + // SetCursorVisible controls whether the mouse cursor is currently visible. + SetCursorVisible(visible bool) + + // OnMouseMove registers a callback function for mouse move events. + OnMouseMove(callback MouseMoveCallback) + // OnMouseButtonDown registers a callback function for mouse button down events. + OnMouseButtonDown(callback MouseButtonCallback) + // OnMouseButtonUp registers a callback function for mouse button up events. + OnMouseButtonUp(callback MouseButtonCallback) + // OnMouseScroll registers a callback function for mouse scroll events. + OnMouseScroll(callback MouseScrollCallback) + + // OnKeyPress registers a callback function for key events. + OnKeyPress(callback KeyCallback) + // OnKeyRelease registers a callback function for key events. + OnKeyRelease(callback KeyCallback) + // OnModifier registers a callback function for change of modifier events. + OnModifier(callback ModifierCallback) + // OnCharCallback registers a callback function for typed characters. + OnCharCallback(callback CharCallback) + + // OnFileDropCallback registers a callback function for dropped files. + OnFileDropCallback(callback FileDropCallback) +} diff --git a/modding/hacked/ui/opengl/WindowEventDispatcher.go b/modding/hacked/ui/opengl/WindowEventDispatcher.go new file mode 100644 index 0000000..ed394db --- /dev/null +++ b/modding/hacked/ui/opengl/WindowEventDispatcher.go @@ -0,0 +1,128 @@ +package opengl + +import ( + "github.com/inkyblackness/hacked/ui/input" +) + +type keyDeferrer struct { + window *WindowEventDispatcher +} + +func (def *keyDeferrer) KeyPress(key input.Key, modifier input.Modifier) { + def.window.CallKeyPress(key, modifier) +} + +func (def *keyDeferrer) KeyRelease(key input.Key, modifier input.Modifier) { + def.window.CallKeyRelease(key, modifier) +} + +func (def *keyDeferrer) Modifier(modifier input.Modifier) { + def.window.CallModifier(modifier) +} + +// WindowEventDispatcher implements the common, basic functionality of Window. +type WindowEventDispatcher struct { + CallClosing ClosingCallback + CallClosed ClosedCallback + CallRender RenderCallback + CallResize ResizeCallback + CallOnMouseMove MouseMoveCallback + CallOnMouseButtonUp MouseButtonCallback + CallOnMouseButtonDown MouseButtonCallback + CallOnMouseScroll MouseScrollCallback + CallModifier ModifierCallback + CallKeyPress KeyCallback + CallKeyRelease KeyCallback + CallCharCallback CharCallback + CallFileDropCallback FileDropCallback +} + +// NullWindowEventDispatcher returns an initialized instance with empty callbacks. +func NullWindowEventDispatcher() WindowEventDispatcher { + return WindowEventDispatcher{ + CallClosing: func() {}, + CallClosed: func() {}, + CallRender: func() {}, + CallResize: func(int, int) {}, + CallOnMouseMove: func(float32, float32) {}, + CallOnMouseButtonUp: func(uint32, input.Modifier) {}, + CallOnMouseButtonDown: func(uint32, input.Modifier) {}, + CallOnMouseScroll: func(float32, float32) {}, + CallKeyPress: func(input.Key, input.Modifier) {}, + CallKeyRelease: func(input.Key, input.Modifier) {}, + CallModifier: func(input.Modifier) {}, + CallCharCallback: func(rune) {}, + CallFileDropCallback: func([]string) {}, + } +} + +// StickyKeyListener returns an instance of a listener acting as an adapter +// for the key-down/-up callbacks. +func (window *WindowEventDispatcher) StickyKeyListener() input.StickyKeyListener { + return &keyDeferrer{window: window} +} + +// OnClosing implements the Window interface. +func (window *WindowEventDispatcher) OnClosing(callback ClosingCallback) { + window.CallClosing = callback +} + +// OnClosed implements the Window interface. +func (window *WindowEventDispatcher) OnClosed(callback ClosedCallback) { + window.CallClosed = callback +} + +// OnRender implements the Window interface. +func (window *WindowEventDispatcher) OnRender(callback RenderCallback) { + window.CallRender = callback +} + +// OnResize implements the Window interface. +func (window *WindowEventDispatcher) OnResize(callback ResizeCallback) { + window.CallResize = callback +} + +// OnMouseMove implements the Window interface. +func (window *WindowEventDispatcher) OnMouseMove(callback MouseMoveCallback) { + window.CallOnMouseMove = callback +} + +// OnMouseButtonDown implements the Window interface. +func (window *WindowEventDispatcher) OnMouseButtonDown(callback MouseButtonCallback) { + window.CallOnMouseButtonDown = callback +} + +// OnMouseButtonUp implements the Window interface. +func (window *WindowEventDispatcher) OnMouseButtonUp(callback MouseButtonCallback) { + window.CallOnMouseButtonUp = callback +} + +// OnMouseScroll implements the Window interface. +func (window *WindowEventDispatcher) OnMouseScroll(callback MouseScrollCallback) { + window.CallOnMouseScroll = callback +} + +// OnKeyPress implements the Window interface. +func (window *WindowEventDispatcher) OnKeyPress(callback KeyCallback) { + window.CallKeyPress = callback +} + +// OnKeyRelease implements the Window interface. +func (window *WindowEventDispatcher) OnKeyRelease(callback KeyCallback) { + window.CallKeyRelease = callback +} + +// OnModifier implements the WindowEventDispatcher interface. +func (window *WindowEventDispatcher) OnModifier(callback ModifierCallback) { + window.CallModifier = callback +} + +// OnCharCallback implements the Window interface. +func (window *WindowEventDispatcher) OnCharCallback(callback CharCallback) { + window.CallCharCallback = callback +} + +// OnFileDropCallback implements the Window interface. +func (window *WindowEventDispatcher) OnFileDropCallback(callback FileDropCallback) { + window.CallFileDropCallback = callback +} diff --git a/modding/hacked/ui/opengl/constants.go b/modding/hacked/ui/opengl/constants.go new file mode 100644 index 0000000..c67fef0 --- /dev/null +++ b/modding/hacked/ui/opengl/constants.go @@ -0,0 +1,156 @@ +package opengl + +// Buffer Bits +// nolint: golint,megacheck +const ( + DEPTH_BUFFER_BIT uint32 = 0x00000100 + STENCIL_BUFFER_BIT = 0x00000400 + COLOR_BUFFER_BIT = 0x00004000 +) + +// Draw Types +// nolint: golint,megacheck +const ( + POINTS uint32 = 0x0000 + LINES = 0x0001 + LINE_LOOP = 0x0002 + LINE_STRIP = 0x0003 + TRIANGLES = 0x0004 + TRIANGLE_STRIP = 0x0005 + TRIANGLE_FAN = 0x0006 +) + +// Shader Types +// nolint: golint,megacheck +const ( + FRAGMENT_SHADER uint32 = 0x8B30 + VERTEX_SHADER = 0x8B31 +) + +// Status Values +// nolint: golint,megacheck +const ( + COMPILE_STATUS uint32 = 0x8B81 + LINK_STATUS = 0x8B82 +) + +// Buffer Types +// nolint: golint,megacheck +const ( + ARRAY_BUFFER uint32 = 0x8892 + ELEMENT_ARRAY_BUFFER = 0x8893 +) + +// Framebuffer Types +// nolint: golint,megacheck +const ( + FRAMEBUFFER = 0x8D40 + RENDERBUFFER = 0x8D41 + + DEPTH_COMPONENT24 = 0x81A6 + + COLOR_ATTACHMENT0 = 0x8CE0 + DEPTH_ATTACHMENT = 0x8D00 +) + +// Draw Types +// nolint: golint,megacheck +const ( + STREAM_DRAW uint32 = 0x88E0 + STATIC_DRAW = 0x88E4 + DYNAMIC_DRAW = 0x88E8 +) + +// Features +// nolint: golint,megacheck +const ( + BLEND uint32 = 0x0BE2 + DEPTH_TEST = 0x0B71 + CULL_FACE = 0x0B44 + SCISSOR_TEST = 0x0C11 + + NICEST = 0x1102 + LINE_SMOOTH = 0x0B20 + LINE_SMOOTH_HINT = 0x0C52 + + ACTIVE_TEXTURE = 0x84E0 + CURRENT_PROGRAM = 0x8B8D + TEXTURE_BINDING_2D = 0x8069 + SAMPLER_BINDING = 0x8919 + ARRAY_BUFFER_BINDING = 0x8894 + ELEMENT_ARRAY_BUFFER_BINDING = 0x8895 + VERTEX_ARRAY_BINDING = 0x85B5 + POLYGON_MODE = 0x0B40 + VIEWPORT = 0x0BA2 + SCISSOR_BOX = 0x0C10 + BLEND_SRC_RGB = 0x80C9 + BLEND_DST_RGB = 0x80C8 + BLEND_SRC_ALPHA = 0x80CB + BLEND_DST_ALPHA = 0x80CA + BLEND_EQUATION_RGB = 0x8009 + BLEND_EQUATION_ALPHA = 0x883D + FRONT_AND_BACK = 0x0408 + FILL = 0x1B02 +) + +// Alpha constants +// nolint: golint,megacheck +const ( + SRC_ALPHA uint32 = 0x0302 + ONE_MINUS_SRC_ALPHA = 0x0303 + ONE_MINUS_SRC_COLOR = 0x0301 + + FUNC_ADD = 0x8006 +) + +// Data Types +// nolint: golint,megacheck +const ( + BYTE uint32 = 0x1400 + UNSIGNED_BYTE = 0x1401 + SHORT = 0x1402 + UNSIGNED_SHORT = 0x1403 + INT = 0x1404 + UNSIGNED_INT = 0x1405 + FLOAT = 0x1406 +) + +// Texture Constants +// nolint: golint,megacheck +const ( + TEXTURE_2D uint32 = 0x0DE1 + + TEXTURE0 = 0x84C0 + + NEAREST = 0x2600 + TEXTURE_MAG_FILTER = 0x2800 + TEXTURE_MIN_FILTER = 0x2801 + + UNPACK_ROW_LENGTH = 0x0CF2 + + LINEAR = 0x2601 +) + +// Errors +// nolint: golint,megacheck +const ( + NO_ERROR uint32 = 0 + INVALID_ENUM = 0x0500 + INVALID_VALUE = 0x0501 + INVALID_OPERATION = 0x0502 + STACK_OVERFLOW = 0x0503 + STACK_UNDERFLOW = 0x0504 + OUT_OF_MEMORY = 0x0505 + INVALID_FRAMEBUFFER_OPERATION = 0x0506 +) + +// Color Types +// nolint: golint,megacheck +const ( + ALPHA uint32 = 0x1906 + RGBA = 0x1908 + RED = 0x1903 + + RGBA8 = 0x8058 + R8 = 0x8229 +) diff --git a/modding/ss-specs/CONTRIBUTING.md b/modding/ss-specs/CONTRIBUTING.md new file mode 100644 index 0000000..bf22415 --- /dev/null +++ b/modding/ss-specs/CONTRIBUTING.md @@ -0,0 +1,52 @@ +## Contributing Guidelines + +Thank you for considering to contribute to this documentation! +Such a contribution can be useful for modders, tool-, and engine developers. + +The following text lists guidelines for contributing to this documentation. +These guidelines don't have legal status, so use them as a reference and common sense - and feel free to update them as well! + + +### "I just want to know..." + +For questions, or general meta-information about the engine or the data files, please refer to the [systemshock.org](https://www.systemshock.org) forums. +The following sub-sections will most likely be applicable for your question: + +* [Engineering](https://www.systemshock.org/index.php?board=36.0) - for modding the game or creating fan-missions in general +* [Deck 13](https://www.systemshock.org/index.php?board=13.0) - for engine source-code, ports, and tool-development related topics +* [System Shock source code](https://github.com/nothings/shockmac) - source code of System Shock for Mac OS with notes from Sean Barrett + +### Scope + +This documentation is meant to describe the "meaningful" resource files of the original System Shock (1994) and related engine behaviour. +It is meant to give modders and tool-developers (possibly engine developers) a reference on what data to expect. + +What does "meaningful" mean? Data that has effect on the players experience. As a rule of thumb, there are several fields in various structures that are unused. Even with the source code of the engine available, it may be academic to try to document fields and their fictionial behaviour just by their name alone. If a field is never used, it may simply be stated as such and nothing more. +Exceptions are listed on the main readme - for instance, not covering music or Mac-specific files. + +What does "original" mean? The game System Shock from 1994 and its direct derivatives, such as the "Enhanced Edition" or possible (future) source ports that claim to be compatible with the data files. It may be that future source ports extend the behaviour of the original engine. If this is the case, this documentation could be extended alongside, stating the compatibilities. + +This documentation is NOT a documentation on the engine itself. There may be some gray areas if related to behaviour, yet you won't find a documentation on how the renderer is working for instance. + + +### Conventions + +This documentation is meant to be read by a Markdown-compatible viewer, though for the most part, this will remain GitHub itself. +Refer to the [conventions](conventions.md) document for formatting and content. + +To reiterate from this document: Please avoid adding information "on a hunch" to the documentation. Meanwhile the source code has already been released, so if there isn't a test, or a successful tool showcasing information, the documentation should at least hold up to the original source code. + +It is acceptable to change field/type names if they are "near" synonyms (or simply words written in full). The documentation should aim to stay close to the original source, not a particular fan-project (not even tools from InkyBlackness - their code should follow as well.) + + +### Information + +As a rule of thumb, this documentation describes types (data structures) with their fields, names, and related behaviour of the engine. +This is typically a combination of a dry listing of a data structure, as described in the conventions, and then some paragraphs of prose text. + +It should not contain any source code - especially copied from the original source, which uses a different license. The only algorithms explained in this documentation are that of compression, and even these could be covered in prose. + + +### Adding/Changing the documentation + +If you find typos, new information, better (more expressive) descriptions that you would like to see handled, feel free to create an issue, or possibly even a pull-request about this. diff --git a/modding/ss-specs/LICENSE.txt b/modding/ss-specs/LICENSE.txt new file mode 100644 index 0000000..61ab961 --- /dev/null +++ b/modding/ss-specs/LICENSE.txt @@ -0,0 +1 @@ +This work is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/ or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA. diff --git a/modding/ss-specs/README.md b/modding/ss-specs/README.md new file mode 100644 index 0000000..916b785 --- /dev/null +++ b/modding/ss-specs/README.md @@ -0,0 +1,32 @@ +# THE UNOFFICIAL SYSTEM SHOCK 1 SPECIFICATIONS + +This is a sub-project as part of the [InkyBlackness](https://inkyblackness.github.io) project. It aims to document the data (resource) files of the video game [System Shock](http://en.wikipedia.org/wiki/System_Shock). It is the continuation of the previous work performed as part of "The System Shock Hack Project" ([TSSHP](http://tsshp.sourceforge.net/)). + +The goal is to document all meaningful bits and bytes of the original data files. + +Start reading with the [main page](index.md) (the index). + +If you would like to contribute to the documentation, please consider [the guidelines](CONTRIBUTING.md). + +## Status +The content of this documentation supersedes the information available from TSSHP (the [ss-specs.txt](http://tsshp.cvs.sourceforge.net/viewvc/tsshp/tsshp/doc/ss-specs.txt?view=markup) file). In some instances, this documentation even provides more accurate descriptions. + +The format of all raw media content is described, with the exception of a few unknown fields. The same counts for the game objects. +See the [issue list](https://github.com/inkyblackness/ss-specs/issues) for an overview about what is still actively analyzed. +With the release of the original source (see credits below), many so far unknown things can be clarified. + +Currently not in scope of this documentation project are music files (See [here for details](other/Music.md)) and the Macintosh release. + + +## Credits +* This project, as well as InkyBlackness itself, wouldn't be possible without the work of TSSHP and the effort of the people before that. +* [Source release](https://github.com/NightDiveStudios/shockmac) of the Mac version, which finally allowed to figure out unknown details, and set some guesswork naming right. + +## License + +[![License][license-image]][license-url] + +This work is licensed under a [Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License](http://creativecommons.org/licenses/by-nc-sa/4.0/). + +[license-url]: http://creativecommons.org/licenses/by-nc-sa/4.0/ +[license-image]: https://i.creativecommons.org/l/by-nc-sa/4.0/88x31.png diff --git a/modding/ss-specs/archives/gameState.md b/modding/ss-specs/archives/gameState.md new file mode 100644 index 0000000..0a74611 --- /dev/null +++ b/modding/ss-specs/archives/gameState.md @@ -0,0 +1,336 @@ +## Game state and hacker information, resource 0x0FA1 (`Player` struct) + +This resource contains everything about the hacker, together with general game-state information. + + + 0000 20xbyte Hacker name (incl. 0x00 termination char). Game only allows 12 characters for input at start. + 0014 byte Realspace Level; The level to return to after exiting cyberspace. + + 0015 4xbyte Difficulties (Game rating): combat, mission, puzzle, cyber. All 0..3 + 0019 3xbyte Unused. + + 001C uint32 Game time + 0020 uint32 Last second update + 0024 uint32 Last drug update + 0028 uint32 Last ware update + 002C uint32 Last animation check + 0030 int32 Queue Time + 0034 int32 Delta Time + 0038 byte Detail Level + + 0039 byte Current level identifier + 003A 22xint16 Initial SHODAN levels + 0066 6xbyte Controls + 006C int16 Player Object ID + 006E [8]byte Realspace location; The point to return to after exiting cyberspace. + 0076 int32 Version number of the data structure; Value: 6 + 007A [14]int16 General inventory references, Object IDs + + 0096 byte Posture + 0097 byte Foot planted; 1: on the floor + 0098 byte Lean X + 0099 byte Lean Y + 009A int16 Unused + + 009C byte Health; 0x00..0xFF + 009D byte Cyberspace Integrity + 009E uint16 Hitpoint regeneration per minute + 00A0 8xbyte Damage rate per minute [explosion, energy, magnetic, radiation, gas, tranq, needle, bio] + 00A8 uint16 Bio post expose + 00AA uint16 Rad post expose + 00AC byte Power 0x00..0xFF + 00AD byte Power usage in JPM + 00AE byte Power regeneration + 00AF byte Energy out; 1: is out of energy + 00B0 int16 Cyberspace trips + 00B2 int32 Cyberspace time base + + 00B6 [64]uint8 512 Boolean game variables; Bytes are counted up, bits within byte counted from LSB; Example: bit[10] = 0x00 0x04 0x.. + 00F6 [64]int16 64 Integer game variables + + 0176 uint32 HUD modes; Bitfield as per hud.h + 017A byte Unused + 017B sint32 Fatigue + 017F uint16 Fatigue spending; Current rate of fatigue expenditure in pts/sec + 0181 uint16 Fatigue regeneration + 0183 uint16 Fatigue regeneration base + 0185 uint16 Fatigue regeneration maximum + 0187 sint8 Accuracy + 0188 byte Shield absorb rate, in percent + 0189 byte Shield threshold + 018A byte Light value; setting of lamp + + Multi-Function-Display (MFD) state + 018B [10]byte 2x5 MFD slot indices + 0195 [7]byte MFD status + 019C [7]byte MFD function IDs + 01A3 [32]byte MFD function flags + 01C3 [256]byte MFD function data + 02C3 [2]byte current MFDs + 02C5 [2]byte MFD empty functions + 02C7 [64]byte MFD access puzzles + 0307 [2]byte MFD save slots + + + Installed Hardware (0: not installed): + 0309 byte version for infrared night vision + 030A byte version for target info + 030B byte version for Sensaround + 030C byte version for "Aim Enhancement Hardware" + 030D byte version for HUD (unknown function) + 030E byte version for bioscan + 030F byte version for navigation unit + 0310 byte version for shield + 0311 byte version for data reader + 0312 byte version for lantern + 0313 byte version for view control + 0314 byte version for enviro suit + 0315 byte version for booster + 0316 byte version for jump jet + 0317 byte version for system status + + Installed Software(0: not installed): + 0318 [7]byte offensive software + 031F [3]byte defensive software + 0322 [9]byte misc software + + Full clips of ammunition + 032B byte ML STANDARD ROUNDS + 032C byte ML TEFLON COATED ROUNDS + 032D byte SV NEEDLE DARTS + 032E byte SV TRANQ DARTS + 032F byte HOLLOW-TIP 2100 CLIP + 0330 byte HEAVY SLUG 2100 CLIP + 0331 byte DC RUBBER SLUGS + 0332 byte MARK3 MAGNESIUM-TIP SHELLS + 0333 byte MARK3 PENETRATOR SHELLS + 0334 byte AM HORNET CLIP + 0335 byte AM SPLINTER CLIP + 0336 byte RF SLAG CLIP + 0337 byte RF LARGE SLAG CLIP + 0338 byte SG MAG PULSE CART + 0339 byte MM RAIL CLIP + + Ammunition not in full clips + 033A byte ML STANDARD ROUNDS + 033B byte ML TEFLON COATED ROUNDS + 033C byte SV NEEDLE DARTS + 033D byte SV TRANQ DARTS + 033E byte HOLLOW-TIP 2100 CLIP + 033F byte HEAVY SLUG 2100 CLIP + 0340 byte DC RUBBER SLUGS + 0341 byte MARK3 MAGNESIUM-TIP SHELLS + 0342 byte MARK3 PENETRATOR SHELLS + 0343 byte AM HORNET CLIP + 0344 byte AM SPLINTER CLIP + 0345 byte RF SLAG CLIP + 0346 byte RF LARGE SLAG CLIP + 0347 byte SG MAG PULSE CART + 0348 byte MM RAIL CLIP + + Patches + 0349 byte STAMINUP STIMULANT + 034A byte SIGHT VISION ENCHANCEMENT + 034B byte BERSERK COMBAT BOOSTER + 034C byte MEDIPATCH HEALING AGENT + 034D byte REFLEX REACTION AID + 034E byte GENIUS MIND-ENCHANCER + 034F byte DETOX UNIVERSAL ANTIDOTE + + Explosives + 0350 byte FRAGMENTATION GRENADE + 0351 byte EMP GRENADE + 0352 byte GAS GRENADE + 0353 byte CONCUSSION BOMB + 0354 byte LAND MINE + 0355 byte NITROPACK + 0356 byte EARTH SHAKER EXPLOSIVE + + 0357 [294]byte Message status bitfield; 47 emails + 23 data fragments + (16 logs * 14 levels) + 047D [14]byte Level log marker; On which level logs are in inventory + + 048B [5]byte First weapon + +0 byte Subclass (0xFF means no weapon) + +1 byte Type + +2 byte Rounds / Heat + +3 byte Ammunition type / Energy setting (0x80 bit is overload) + +4 byte Unused + + 0490 [5]byte Second weapon + 0495 [5]byte Third weapon + 049A [5]byte Fourth weapon + 049F [5]byte Fifth weapon + 04A4 [5]byte Sixth weapon + 04A9 [5]byte Seventh weapon + + 04AE [15]byte Hardware on/off + 04BD [19]byte Software status on/off + + 04D0 byte Jumpjet energy fraction + 04D1 [32]byte email sender count + 04F1 [7]byte Patches time remainder + 04F8 [7]byte Patches intensity + + 04FF [7]uint16 Timer settings of explosives, in 0.1 seconds; Default: 0x46 = 7 seconds + + 050D uint16 Unused + 050F uint16 Time to completion of current program (seconds). Essentially unused, although used to print some strings + + 0511 uint16 ObjectID of current target + 0513 uint32 Last weapon fire time + 0517 uint16 Weapon fire rate; Time needed before next fire. + + 0519 [10]byte HUD actives: weapon, grenade, drug, cart, hardware, combat soft, defense soft, misc soft, general, email + + 0523 uint16 Object under cursor before cyberspace + 0525 uint16 Last panel reference + + 0527 sint32 Kill count + 052B sint32 Time in cyberspace + 052F sint32 Number of shots fired + 0533 sint32 Number of hits + + 0537 sint32 Death count; How often hacker was resurrected. + 053B int32 Tilt (eye position) + + 053F [12]fix Physics state (EDMS) - see below + + 056F byte Currently active inventory category + 0570 byte Active bio tracks + + 0571 int16 current email + + 0573 [6]byte version + 0579 byte dead flag + 057A uint16 lean filter state + 057C [2]byte unused + 057E byte MFD save field + + 057F uint32 Auto fire click + 0583 uint32 Posture state + 0587 byte Option: Text Length (0: normal, 1: terse) + 0588 uint32 Last head bob + 058C [9]byte Padding + + +> In archive.dat, this table is (nearly) all 0x00, and also slightly smaller than necessary. + + +### Physics state (EDMS `State` structure, 48 bytes) + + 0000 fix X Coordinate (East -> West) + 0004 fix Y Coordinate (North -> South) + 0008 fix Z Coordinate (above ground) + 000C fix Yaw; 0 is facing East, going counter-clockwise + 0010 fix Upper body back force (?) + 0014 fix Upper body left force (?) + 0018 fix Forward force (?) -- X dot product + 001C fix Left force (?) -- Y dot product + 0020 fix Unknown -- Z dot product + 0024 fix Unknown + 0028 fix Unknown + 002C fix Unknown + +Rotation values are in range 0..2PI. + +### Message status enumeration (1 byte) + + 0x80 Message received + 0x40 Message read + 0x3F Unused + + +### Game variables + +Game variables are described on a [separate page](gameVariables.md) + +### Initial game state + +The original engine does not allow for having `archive.dat` specify the initial state for both the game and hacker. + +The `System Shock Enhanced Edition Source Port` introduced a modified start behaviour, +which allows for (almost) full control on how and where the protagonist starts: +When the first field of the `physics state` (the `X` coordinate of EDMS state) is not zero, +then most default settings are skipped, and data is taken from `archive.dat`. +Still, some minimal setup is performed either way, in order to have a stable game. + +> This behaviour exists already in the original engine, but it only considers the physics structure itself, +> and no further information, such as the current level or any game variable. + +#### Mission-independent hardcoded defaults + +The following defaults are being applied regardless of existing data in `archive.dat`. + +##### Game state fields + +| Offset | Name | Initial value | +|--------|----------------------------|-----------------------------------------------------| +| 0000 | Hacker name | as per start menu | +| 0015 | Difficulties | as per start menu | +| 0038 | Detail Level | as per configuration | +| 003A | Initial SHODAN levels | all to `-1` | +| 006C | Player Object ID | `0` | +| 00A0 | Damage rate per minute | all to `0` | +| 0511 | ObjectID of current target | `0` | +| 0513 | Last weapon fire time | `0` | +| ** | Various MFD fields | To a consistent (empty) MFD display | +| 0309 | Installed Hardware[10] | `1` to hardcode "fullscreen hardware" | +| 0570 | Active bio tracks | `0xFF` | +| 00F6 | Integer game variables[48] | Language, as per configuration | + +The two "random" game variables (index 31 and 32) are randomized only if they are equal. + + +#### Mission-dependent defaults + +The following defaults are being applied *only* if `physics state` field `X` is zero. + +##### Game state fields + +| Offset | Name | Initial value | +|--------|----------------------------|-----------------------------------------------------| +| 0039 | Current level identifier | `1` | +| 007A | General Inventory | all to `0` | +| 009C | Health | `212` | +| 009D | Cyberspace Integrity | `255` | +| 009E | Hitpoint regeneration | `0` | +| 00AC | Power | `255` | +| 00B2 | Cyberspace time base | `504000` (30 minutes in 280 ticks-per-second) | +| 0181 | Fatigue regeneration | `0` | +| 0183 | Fatigue regeneration base | `100` | +| 0183 | Fatigue regeneration max | `400` | +| 0187 | Accuracy | `100` | +| 0188 | Shield absorb rate | `0` | +| 0357 | Message status[26] | `0x80`, to mark email from Rebecca received | +| 048B | Weapons[*].type | `0xFF`, to mark no weapon in slot | +| 04FF | Explosives timer[*] | `70` (= 7 seconds) | +| 0519 | HUD actives[9] | `0xFF`, to mark email being active | +| 053F | Physics state (EDMS) | X=30.5, Y=22.5, Z=planted at floor, Alpha=West | + + +##### Variables + +The following integer variables are set: + +| Integer | Value | +|---------|--------| +| 3 | 2 | +| 12 | 3 | +| 51 | 256 | + +And the following boolean variables are set to `true`: + +``` +0x001, 0x002, 0x003, 0x010, 0x012, 0x015, 0x016, 0x017, 0x018, 0x019, 0x01A, +0x020, 0x021, 0x024, 0x025, +0x04B, 0x04C, 0x04D, 0x04E, 0x04F, +0x050, 0x051, 0x052, 0x053, 0x054, 0x055, 0x056, 0x057, 0x058, 0x059, 0x05A, 0x05B, 0x05C, 0x05D, 0x05E, 0x05F, +0x070, 0x071, 0x072, 0x073, 0x074, 0x075, 0x076, 0x077, 0x078, 0x079, 0x07A, 0x07B, 0x07C, 0x07D, 0x07E, 0x07F, +0x0A0, 0x0A1, 0x0A2, 0x0A3, 0x0A4, 0x0A5, 0x0A6, 0x0A7, 0x0A8, 0x0A9, +0x0C0, 0x0C1, 0x0C2, 0x0C3, 0x0C4, 0x0C5, 0x0C6, 0x0C7, 0x0C8, 0x0C9, 0x0CA, 0x0CB, 0x0CC, 0x0CD, 0x0CE, 0x0CF, +0x0E1, 0x0E3, 0x0E5, 0x0E7, 0x0E9, 0x0EB, 0x0ED, 0x0EF, +0x0F1, 0x0F3, 0x0F5, 0x0F7, 0x0F9, 0x0FB, 0x0FD, 0x0FF, +0x101, 0x103, 0x105, 0x107, 0x109, 0x10B, 0x10D, 0x10F, +0x111, 0x113, 0x115, 0x117, 0x119, 0x11B, 0x11D, 0x11F, +0x121, 0x123, 0x125, 0x127, 0x129, 0x12B, +``` diff --git a/modding/ss-specs/archives/gameVariables.md b/modding/ss-specs/archives/gameVariables.md new file mode 100644 index 0000000..5cf307a --- /dev/null +++ b/modding/ss-specs/archives/gameVariables.md @@ -0,0 +1,208 @@ +## Game variables + +This page lists all game variables with their index, their initital value, and their usage in the original game. + +> At the moment not all variables have been catalogued. Only the initial values have been determined. + +### Boolean variables + +| Index | Initial Value | Usage | +|------:|--------------:|----------------------------------------------------------------------------------------------| +| 0 | 0 | Must always be 0, otherwise default conditions may break | +| 1 | 1 | Must always be 1, otherwise some locks may fail | +| 2 | 1 | Unknown | +| 3 | 1 | Unknown | +| 6 | 0 | Shield status (+ System Analyzer) | +| 8 | 0 | Laser status (+ System Analyzer); 0: charging, 1: destroyed | +| 10 | 0 | Delta grove launch enable (+ System Analyzer) | +| 11 | 0 | Alpha grove launch enable (+ System Analyzer) | +| 12 | 0 | Beta grove launch enable (+ System Analyzer) | +| 13 | 0 | Relay 428 report status; 0: functioning, 1: faulty; Set by main switch, checked by diag. | +| 14 | 0 | Relay 428 repair status; 0: faulty, 1: fixed; Set by panel, checked by main switch. | +| 15 | 0 | Beta grove jettisoned (+ System Analyzer) | +| 16 | 1 | Unknown | +| 18 | 1 | Unknown | +| 20 | 0 | Reactor on destruct, pods enabled; Quakes (+ System Analyzer) | +| 21 | 1 | Unknown | +| 22 | 1 | Unknown | +| 23 | 1 | Unknown | +| 24 | 1 | Unknown | +| 25 | 1 | Unknown | +| 26 | 1 | Medical armory lock on doors in level 1 | +| 30 | 0 | Switch to open armory doors on level 1 via cyberspace level 14 | +| 32 | 1 | Unknown | +| 33 | 1 | Unknown | +| 36 | 1 | Unknown | +| 37 | 1 | Unknown | +| 75 | 1 | Unknown | +| 76 | 1 | Unknown | +| 77 | 1 | Unknown | +| 78 | 1 | Unknown | +| 79 | 1 | Unknown | +| 80 | 1 | Unknown | +| 81 | 1 | Unknown | +| 82 | 1 | Unknown | +| 83 | 1 | Unknown | +| 84 | 1 | Unknown | +| 85 | 1 | Unknown | +| 86 | 1 | Unknown | +| 87 | 1 | Unknown | +| 88 | 1 | Unknown | +| 89 | 1 | Unknown | +| 90 | 1 | Unknown | +| 91 | 1 | Unknown | +| 92 | 1 | Unknown | +| 93 | 1 | Unknown | +| 94 | 1 | Unknown | +| 95 | 1 | Unknown | +| 112 | 1 | Unknown | +| 113 | 1 | Unknown | +| 114 | 1 | Unknown | +| 115 | 1 | Unknown | +| 116 | 1 | Unknown | +| 117 | 1 | Unknown | +| 118 | 1 | Unknown | +| 119 | 1 | Unknown | +| 120 | 1 | Unknown | +| 121 | 1 | Unknown | +| 122 | 1 | Unknown | +| 123 | 1 | Unknown | +| 124 | 1 | Unknown | +| 125 | 1 | Unknown | +| 126 | 1 | Unknown | +| 127 | 1 | Unknown | +| 145 | 0 | "Online" help (tooltips); 0: show, 1: off | +| 160 | 1 | Unknown | +| 161 | 1 | Unknown | +| 162 | 1 | Unknown | +| 163 | 1 | Unknown | +| 164 | 1 | Unknown | +| 165 | 1 | Unknown | +| 166 | 1 | Unknown | +| 167 | 1 | Unknown | +| 168 | 1 | Unknown | +| 169 | 1 | Unknown | +| 192 | 1 | Unknown | +| 193 | 1 | Unknown | +| 194 | 1 | Unknown | +| 195 | 1 | Unknown | +| 196 | 1 | Unknown | +| 197 | 1 | Unknown | +| 198 | 1 | Unknown | +| 199 | 1 | Unknown | +| 200 | 1 | Unknown | +| 201 | 1 | Unknown | +| 202 | 1 | Unknown | +| 203 | 1 | Unknown | +| 204 | 1 | Unknown | +| 205 | 1 | Unknown | +| 206 | 1 | Unknown | +| 207 | 1 | Unknown | +| 225 | 1 | Unknown | +| 227 | 1 | Unknown | +| 229 | 1 | Unknown | +| 231 | 1 | Unknown | +| 233 | 1 | Unknown | +| 235 | 1 | Unknown | +| 237 | 1 | Unknown | +| 239 | 1 | Unknown | +| 241 | 1 | Unknown | +| 243 | 1 | Unknown | +| 245 | 1 | Unknown | +| 247 | 1 | Unknown | +| 249 | 1 | Unknown | +| 251 | 1 | Unknown | +| 253 | 1 | Unknown | +| 255 | 1 | Unknown | +| 257 | 1 | Unknown | +| 259 | 1 | Unknown | +| 261 | 1 | Unknown | +| 263 | 1 | Unknown | +| 265 | 1 | Unknown | +| 267 | 1 | Unknown | +| 269 | 1 | Unknown | +| 271 | 1 | Unknown | +| 273 | 1 | Unknown | +| 275 | 1 | Unknown | +| 277 | 1 | Unknown | +| 279 | 1 | Unknown | +| 281 | 1 | Unknown | +| 283 | 1 | Unknown | +| 285 | 1 | Unknown | +| 287 | 1 | Unknown | +| 289 | 1 | Unknown | +| 291 | 1 | Unknown | +| 293 | 1 | Unknown | +| 295 | 1 | Unknown | +| 297 | 1 | Unknown | +| 299 | 1 | Unknown | +| 300 | 0 | Set to 1 while the data reader icon is flashing & beeping | + + +### Integer variables + +| Index | Initial Value | Usage | +|------:|--------------:|---------------------------------------------------------| +| 1 | 0 | Incremented for each destroyed computer node lvls 1-7 | +| 3 | 2 | Engine state | +| 9 | 0 | Incremented by 1 for laser, beta grove, reactor | +| 12 | 3 | Number of groves | +| 13 | Mission value | Unknown | +| 14 | Cyber value | Unknown | +| 15 | Combat value | Unknown | +| 16 | L00 security | Security value of level | +| 17 | L01 security | Security value of level | +| 18 | L02 security | Security value of level | +| 19 | L03 security | Security value of level | +| 20 | L04 security | Security value of level | +| 21 | L05 security | Security value of level | +| 22 | L06 security | Security value of level | +| 23 | L07 security | Security value of level | +| 24 | L08 security | Security value of level | +| 25 | L09 security | Security value of level | +| 26 | L10 security | Security value of level | +| 27 | L11 security | Security value of level | +| 28 | L12 security | Security value of level | +| 29 | L13 security | Security value of level | +| 30 | Puzzle value | Unknown | +| 31 | random | Reactor code 1 (3-digit BCD) | +| 32 | random | Reactor code 2 (3-digit BCD) | +| 33 | 0 | Destroyed comp. nodes on lvl1 before showing digit | +| 34 | 0 | Destroyed comp. nodes on lvl2 before showing digit | +| 35 | 0 | Destroyed comp. nodes on lvl3 before showing digit | +| 36 | 0 | Destroyed comp. nodes on lvl4 before showing digit | +| 37 | 0 | Destroyed comp. nodes on lvl5 before showing digit | +| 38 | 0 | Destroyed comp. nodes on lvl6 before showing digit | +| 39 | 0 | Destroyed cyberguards in large cspace / open barrier | +| 40 | | Unknown | +| 41 | as per config | Audio music volume (0x00 .. 0x64) | +| 42 | 0x4A3D | Video gamma | +| 43 | as per config | Audio digital FX volume (0x00 .. 0x64) | +| 44 | as per config | Mouse handedness (Right: 0, Left: 1) | +| 45 | | Unknown | +| 46 | | Unknown | +| 47 | 0x5555 | Double click time | +| 48 | as per config | Selected language (0: ENG, 1: FRA, 2: GER) | +| 49 | as per config | Audio message volume (0x00 .. 0x64) | +| 50 | as per config | Video resolution | +| 51 | 256 | Joystick sensitivity | +| 52 | | Unknown | +| 53 | as per config | Audio messages (0: Text, 1: Speech, 2: Both) | +| 54 | | Unknown | +| 55 | | Unknown | +| 56 | | Unknown | +| 57 | | Unknown | +| 58 | as per config | Audio channels (0: 2ch, 1: 4ch, 2: 8ch) | +| 59 | | Unknown | +| 60 | | Unknown | +| 61 | | Unknown | +| 62 | | Unknown | +| 63 | | Unknown | + + +The two random codes (index 31, 32) will be randomized if they are equal. + +> The two random codes are stored in [Binary-Coded-Decimal](https://en.wikipedia.org/wiki/Binary-coded_decimal). + +> The integer values from index 40 on seem to be all about in-game options. +> This may be the reason why it was earlier thought that there are only 40 game variables. diff --git a/modding/ss-specs/archives/index.md b/modding/ss-specs/archives/index.md new file mode 100644 index 0000000..9fbcffe --- /dev/null +++ b/modding/ss-specs/archives/index.md @@ -0,0 +1,80 @@ +## Archives +This section covers the archive files. There is one template archive file, called ```archive.dat```, and a number of save-game files, called ```savgamXX.dat```. All of these files are regular resource files. + +Any new game is based on the archive.dat file. All these archive files contain the complete game state, including the level layout. In other words: Citadel itself, with all its content, is represented through such a file; Properties and media for the content is in the other resource files. + +## Map specific data +Each level has 98 resources reserved, but only 51 in use. The level-specific resources are grouped according to the level identifier. For example, on Citadel level R, the resources start with ID ```4002``` (decimal) up to ```4053```. Level 1 uses ```4102``` to ```4153``` and so forth. + +On the following pages, resources identified as ```LXX``` refer to level-specific resource ```XX``` for any level. These numbers will always be given in decimal. + +## Extra Resources + +### Name of archive, resource 0x0FA0 (4000) +This resource contains the name of the archive (title of the save-game). It is a 0x00 terminated string, with a dynamic length of up to 32 bytes (including the termination character). + +> The archive.dat file contains extra (unknown) data after the name in this resource, up to a complete length of 128 bytes. A save-game file has only the name in this resource. The name of the template archive is "Starting Game". + +### [Game state resource 0x0FA1 (4001)](gameState.md) +This resource contains information about the game and the hacker. +It also contains the [level variables](levelVariables.md). + +### Additional Resources + +Save-game files contain 4 further resources that are not present in archive.dat : + +* 0x0000 Unknown int32. ```0x00000000``` at first investigation +* 0x024E Unknown. +* 0x024F Unknown. +* 0x0F9F Unknown int32. ```0x00004444``` at first investigation + +## Index + +### By topic + * [Map Information](mapInformation.md) + * [Level Objects](levelObjects.md) + * [Surveillance Cameras](surveillanceCameras.md) + * [Loop Configuration](loopConfiguration.md) + + +### By level-specific resource ID + * ```L02``` Map version number. int32 with value ```0x0000000B``` for all maps. + * ```L03``` Object version number. int32 with value ```0x0000001B``` for all maps. + * ```L04``` [Basic level information](mapInformation.md) + * ```L05``` [Map Layout](mapInformation.md) + * ```L06``` [Level schedules](levelSchedules.md) + * ```L07``` [Level texture map](mapInformation.md) + * ```L08``` [Main object table](levelObjects.md#level-object-table) + * ```L09``` [Level object cross-reference table](levelObjects.md#level-object-cross-reference-table) + * ```L10``` to ```L24``` 15 object [class specific tables](levelObjects.md#class-tables-and-entries) + * ```L25``` to ```L39``` 15 object [class default info](levelObjects.md#class-default-information) + + * ```L40``` Miscellaneous savefile version number. int32. For all maps: ```CD-Release```: ```0x0000000D```. All other: ```0x0000000B``` + * ```L41``` Unused entry. byte with value ```0x00``` for all maps. + + * ```L42``` [Texture animation](textureAnimation.md) + * ```L43``` [Surveillance Sources](surveillanceCameras.md) + * ```L44``` [Surveillance Surrogates](surveillanceCameras.md) + * ```L45``` [Level variables](levelVariables.md) + * ```L46``` [Map notes](mapInformation.md#map-notes) + * ```L47``` [Map notes pointer](mapInformation.md#map-notes) + + * ```L48``` Unknown. 0x30 byte length. + * ```L49``` Unknown. 0x1C0 byte length. + * ```L50``` Unknown int16. ```0x0000``` for all maps. + * ```L51``` [Loop Configuration](loopConfiguration.md) + * ```L52``` Unknown int16. (Only present in ```CD-Release```) + * ```L53``` Unknown. 0x40 byte length, all zeroes. (Only present in ```CD-Release```) + + +### Levels of Citadel +This is the level list of the base game: + +* Level 00: Reactor level +* Level 01-09: Levels 1-9 +* Level 10: SHODAN cyberspace +* Level 11: Delta grove +* Level 12: Alpha grove +* Level 13: Beta grove +* Level 14: Cyberspace for levels 1 and 2 +* Level 15: Cyberspace diff --git a/modding/ss-specs/archives/levelObjects.md b/modding/ss-specs/archives/levelObjects.md new file mode 100644 index 0000000..2fafb0f --- /dev/null +++ b/modding/ss-specs/archives/levelObjects.md @@ -0,0 +1,175 @@ +## Level Objects + +Instance data of [Level Objects](../levelObjects/index.md) is stored as part of an archive. Two resources describe general information, then further resources are object class specific. + +### General Information + +#### Level Object Table + +```L08```, a compressed resource, contains a table with general entries about the objects in the current level. +This table contains 872 entries, which houses two lists. + +**Level Object Entry** (`Obj` struct) (27 bytes) + + 0000 byte In use flag. 0: free, 1: in use + 0001 byte Object class + 0002 byte Object subclass + 0003 int16 Class table index + 0005 int16 Cross-Reference table index (also: used list head) + 0007 int16 Next + 0009 int16 Previous + 000B uint16 X Coordinate + 000D uint16 Y Coordinate + 000F byte Z Coordinate (height) + 0010 byte Pitch rotation (around X axis), increasing values turn upwards + 0011 byte Yaw/Heading rotation (around Z axis), increasing values turn rightwards + 0012 byte Roll/Bank rotation (around Y axis), increasing values turn rightwards + 0013 byte Physics (0xFF for unaffected, the default) + 0014 byte Object type + 0015 sint16 Hitpoints + 0017 [4]byte Extra + +The starting point for both lists is the first entry (index zero). This entry is not in use (```In use flag``` is zero). + +The `Cross-Reference table index` field of the starting point refers to the first object of a list of objects in use. For all object entries in use, +their ```Previous``` and ```Next``` fields form a double-linked list, with the exception of the starting point: +The last used object entry refers to the starting point with its ```Next``` value without being referred-to from the starting point. + +The `Next` index of the starting point refers to the first object of a list of objects not in use. +For all object entries not in use, their `Next` field forms a single-linked list. +The `Previous` field of unused objects is not defined and contains arbitrary data. +The last unused object entry refers to the starting point with its ```Next``` value. + +If an object is placed in the world, it has the X, Y and Z coordinates set, as well as the ```Cross-Reference table index``` pointing to a corresponding entry in the cross-reference table. +If an object was spawned by the engine without a location in the world, such as loot from corpses, +then the ```X``` field is ```0xFFFF```, ```Y``` and ```Z``` fields are zero, and the ```Cross-Reference table index``` is zero as well. + +The ```extra``` field is a multi-purpose field, which may hold further properties and/or state. The exact features are environment specific and may also be dependent on the classification of the object. + +##### Global extra + +For some objects, the ```extra``` field is dependent on the object type or class, and the field is applicable to both real world and cyberspace. + +**Basic Extra** (4 bytes) + + 0000 byte Make Info - a general index + 0001 byte Current Frame - for animations + 0002 byte Time Remainder - for animations + 0003 byte Instance Flags - for various purposes + +This is the layout as defined by the engine. Yet, these values may be interpreted/used specifically according to type and/or environment. + + +**Panel Extra** (4 bytes) + + 0000 byte Panel name index; Index into cybstrng 0x0869 + 0001 [3]byte Other + +If the ```Panel name``` resolves to an unknown block or empty string, then the regular object name is displayed. + +**Surfaces Extra* (4 bytes) + + 0000 byte Unknown + 0001 byte Bitmap Index + 0002 [2]byte Unknown + +This is used for simple surfaces, such as icons (`7/2/1`) and paintings (`7/2/4`). + +##### Cyberspace extra + +In case the level is cyberspace, the ```extra``` field contains the following properties. + +**Cyberspace Level Object Extra** (4 bytes) + + 0000 byte Other + 0001 byte ICE presence + 0002 byte Unknown + 0003 byte ICE level + +Level objects in cyberspace can be locked by ICE (Intrusion Countermeasures Electronics). ICE need to be defeated using the drill tool before the locked object can be acquired/used. The ```ICE presence``` field determines whether ICE is present. Any value != ```0x00``` has ICE present. +If present, the health of the ICE is determined by the health of the object. +The ```ICE level``` then determines how aggressive and powerful the ICE is. ```0x00``` is the weakest, ```0xFF``` is the highest. + +> Although the vanilla game uses ICE only on software objects, the engine also supports ICE on other objects, such as cyberspace exits. + +#### Level Object Cross-Reference Table + +```L09```, a compressed resource, is a table that links level objects and the tiles they are in. The index field in the [Tile Map Entry](mapInformation.md) refers to this table, as well as the table index field in the **Level Object Entry**. This table contains 1600 entries. + +**Level Object Cross-Reference Entry** (10 bytes) + + 0000 int16 Tile X + 0002 int16 Tile Y + 0004 int16 Index into level object entry table + 0006 int16 Index for next object + 0008 int16 Index for next tile of same object + +Objects can extend over several tiles. The last field points to the next reference entry about the same object, with different tile coordinates. +This field creates a single-linked list, with the last entry pointing to the first one. +If the object is contained within one tile only, this field points to its own entry. + +The major coordinates of the main object entry match those of the last entry of the cross-reference list. The cross-reference list only contains the X/Y values of the tiles - not sub-tile coordinates. + +The ```Index for next object``` field creates a single-linked list, terminating with a zero value. +This list either refers to all objects within the same tile (if referred to from a tile), or creates a pool of unused cross-reference entries. The start of this pool is the first entry (index zero). +Unused cross-reference entries contain arbitrary data and only the next object index field is valid. + +### Class Specific Information + +In the following resources, all tables have a static count of entries. Each entry has a common prefix. + +**Level Object Prefix** (6 bytes) + + 0000 int16 Index into level object entry table + 0002 int16 Previous + 0004 int16 Next + +The tables contain two lists each, similar to the main object table: +The starting point is the first entry (index zero). The starting point refers to a double-linked list of used entries (via ```Next```), and a single-linked list of unused entries (via ```Previous```). Both lists are terminated by referring back to index zero. + +The ```Index``` field of the starting point does not refer to a level object, it is the index within the same table of the last used entry. (The actual "previous" link to the double-linked list of used objects.) + +> The sum of all possible entries is slightly higher than the total count of possible **Level Object Entries**. + +#### Class Tables and Entries + +The following table lists the the level resources per class type. It also lists how many entries each table contains and how big one entry is. +The name column links to the corresponding details. + +| Class | Level Res | Name | Entry Count | Entry Size (incl. prefix) | +|:-----:|-------------|-------------------------------------------|-------------|---------------------------| +| 0 | ```L10``` | [Gun][L10] | 16 | 8 | +| 1 | ```L11``` | [Ammo][L11] | 32 | 6 | +| 2 | ```L12``` | Physics | 32 | 40 | +| 3 | ```L13``` | [Grenade][L13] | 32 | 12 | +| 4 | ```L14``` | [Drug][L14] | 32 | 6 | +| 5 | ```L15``` | [Hardware][L15] | 8 | 7 | +| 6 | ```L16``` | [Software][L16] | 16 | 9 | +| 7 | ```L17``` | [Big Stuff][L17] | 176 | 16 | +| 8 | ```L18``` | [Small Stuff][L18] | 128 | 16 | +| 9 | ```L19``` | [Fixture][L19] | 64 | 30 | +| 10 | ```L20``` | [Door][L20] | 64 | 14 | +| 11 | ```L21``` | Animating | 32 | 10 | +| 12 | ```L22``` | [Trap][L22] | 160 | 28 | +| 13 | ```L23``` | [Container][L23] | 64 | 21 | +| 14 | ```L24``` | [Critter][L24] | 64 | 46 | + + +[L10]: ../levelObjects/00_Gun/levelGunEntry.md +[L11]: ../levelObjects/01_Ammo/levelAmmoEntry.md +[L13]: ../levelObjects/03_Grenade/levelGrenadeEntry.md +[L14]: ../levelObjects/04_Drug/levelDrugEntry.md +[L15]: ../levelObjects/05_Hardware/levelHardwareEntry.md +[L16]: ../levelObjects/06_Software/levelSoftwareEntry.md +[L17]: ../levelObjects/07_BigStuff/levelBigStuffEntry.md +[L18]: ../levelObjects/08_SmallStuff/levelSmallStuffEntry.md +[L19]: ../levelObjects/09_Fixture/levelFixtureEntry.md +[L20]: ../levelObjects/10_Door/levelDoorEntry.md +[L22]: ../levelObjects/12_Trap/levelTrapEntry.md +[L23]: ../levelObjects/13_Container/levelContainerEntry.md +[L24]: ../levelObjects/14_Critter/levelCritterEntry.md + +### Class Default Information + +Each level has 15 further resources, ```L25``` to ```L39```, one for each object class. The length of these resources is the same as for one class entry of the corresponding specific entries. +This information is used as default for creating new objects. Most of the time, all their bytes are 0x00. diff --git a/modding/ss-specs/archives/levelSchedules.md b/modding/ss-specs/archives/levelSchedules.md new file mode 100644 index 0000000..a7fad14 --- /dev/null +++ b/modding/ss-specs/archives/levelSchedules.md @@ -0,0 +1,119 @@ +## Level Schedules + +```L06``` is a dynamically sized list of currently active schedules. A schedule performs some delayed action at a specific time in the future. +It contains a number of live entries equal to the ```timer count``` field from ```L04``` [(Level Information)](mapInformation.md#Level-information). +Unless the list is full, this list always has one extra "terminator" entry at the end. + +**Level Schedule Entry** (`SchedEvent` struct) (8 bytes) + + 0000 int16 Timestamp, with a scale of 0x100 = 10 seconds + 0002 int16 Type + 0004 byte[4] Data + +```Time``` specifies the timestamp accoring to in-game time. Should the level become activated (either through loading of savegame load or level transition) and the current time is beyond the trigger time, the object is immediately triggered. + +```Type``` specifies how the schedule should be handled on timeout. ```Data``` is then type-specific. + +> The one terminator entry is required to exist, and does not have to contain any useful information at the beginning (can be all zero). +> The engine sorts the schedule entries according to their ```trigger time```. + +For the schedule data, any object index refers to the main object table in the level. + +### Schedule Type 0: Null Schedule Event + +This event does nothing. + + +### Schedule Type 1: Grenade Schedule Event (`GrenSchedEvent` struct) + +**Grenade Schedule Event Data** (4 bytes) + + 0000 int16 Grenade object index + 0002 byte Unique identifier + 0003 byte Unused + +The ```unique identifier``` must match the value from the (live) grenade object in the level to be handled. +This event removes the grenade object on timeout. + + +### Schedule Type 2: Explosion Schedule Event + +This event does nothing. + + +### Schedule Type 3: Door Schedule Event (`DoorSchedEvent` struct) + +**Door Schedule Event Data** (4 bytes) + + 0000 int16 Object index + 0002 uint16 Unique identifier + +If the ```object index``` references an open door, then this door will be triggered to close. + +If the ```object index``` references a "plastiqued antenna relay", then this event causes the explosion and the special handling of these fixtures. + + +### Schedule Type 4: Trap Schedule Event (`TrapSchedEvent` struct) + +**Trap Schedule Event Data** (4 bytes) + + 0000 int16 Target object index + 0002 int16 Source object index + +Triggers the ```target object``` first (without condition check). +If ```source object``` is not -1, it is attempted next, including condition checks. + + +### Schedule Type 5: Exposure Schedule Event + +**Exposure Schedule Event Data** (`SchedExposeData` struct) (4 bytes) + + 0000 sint8 Damage + 0001 uint8 Type + 0002 uint8 TSecs + 0003 uint8 Count -- unused + +This event exposes ```type``` to the hacker for half of the amount given in ```damage```. Damage values are rounded towards the greater number. +If the calculated half is not equal to the ```damage``` value, then another event is re-scheduled with an offset of ```tSecs```. + +> An earlier version of the engine appeared to use ```count``` as a counter towards zero, though this was replaced with the damage-halving algorithm. + + +### Schedule Types 6, 7: Floor, Ceiling Schedule Events (`HeightSchedEvent` struct) + +These events share the same event data and algorithm. + +**Height Schedule Event Data** (4 bytes) + + 0000 sint8 Semaphor + 0001 sint8 Key + 0002 sint8 Steps Remaining + 0003 sint8 SFX Code -- unused + +This event references the height transition referenced by ```semaphor``` (stored in ```L53```) which has to have given ```key``` to be valid. + +```Steps remaining``` indicates both the direction, as well as how many further steps to take. When this counter reaches zero, movement stops. + + +### Schedule Type 8: Light Schedule Event + +This event has no specific data. + +It turns off any muzzle light source and restores the state of the lamp hardware. + + +### Schedule Type 9: Bark Schedule Event (`BarkSchedEvent` struct) + +This event has no specific data. + +It is scheduled by specific ```trap message``` actions (action type 22) to automatically remove the side-image after a certain time from the side-MFD. + + +### Schedule Type 10: EMail Schedule Event (`EmailSchedEvent` struct) + +**EMail Schedule Event Data** (4 bytes) + + 0000 sint16 Data Munge (EMail index) + 0002 byte[2] Unused + +This event is the delayed action of ```receive email``` actions (action type 15). This event is used if the action has a delay higher than zero. diff --git a/modding/ss-specs/archives/levelVariables.md b/modding/ss-specs/archives/levelVariables.md new file mode 100644 index 0000000..f9cd804 --- /dev/null +++ b/modding/ss-specs/archives/levelVariables.md @@ -0,0 +1,36 @@ +## Level Variables + +```L45``` contains level variables, including environmental settings. + +**Level Variables** (94 bytes) + + 0000 int16 Data Length. = 94 + 0002 byte Mist; Unused (0x00) + 0003 byte Gravity; Unused (0x00) + 0004 byte Radiation poisoning level, in 0.5 LBP + 0005 byte Gravity, in 25% OR bio contamination level, in 0.5 LBP + 0006 byte Gravity switch. If 0, ```0005``` is bio contamination. Gravity mod otherwise. + 0007 byte Bio contamination register. Must be >1 to have contamination to register + 0008 byte Radiation register. Must be >1 to have radiation to register + 0009 uint32 Exit Time; Unused in savegame + 000D [3*27]byte 3 Automap Variables entries, for first, second, full-screen (see below) + + +> Hazards are notified from value 0x01, but affect only starting from value 0x02 (1 LBP). + + +**Automap Variables** (27 bytes) + + 0000 byte Initialized. + 0001 byte Zoom; 1 (farthest) .. 5 (nearest) + 0002 sint32 X-coordinate; (as fix32 value) + 0006 sint32 Y-coordinate; (as fix32 value) + 000A uint16 Unknown + 000C uint16 Unknown + 000E uint16 Follow object; The object to focus on; Always 0x0001 (hacker) + 0010 uint16 Sensor object + 0012 uint16 Note object + 0014 uint16 Flags + 0016 uint16 Available flags + 0018 byte Version ID + 0019 uint16 Sensor radius; Scan radius, in tiles and sub-tiles diff --git a/modding/ss-specs/archives/loopConfiguration.md b/modding/ss-specs/archives/loopConfiguration.md new file mode 100644 index 0000000..b2c5fd9 --- /dev/null +++ b/modding/ss-specs/archives/loopConfiguration.md @@ -0,0 +1,24 @@ +## Loop Configuration + +```L51``` is a table containing loop configuration. 64 entries are stored in this table, with unused entries set to 0x00. + +> This table appears to be automatically filled by displaying objects, such as screens and the like. +> Although ```archive.dat``` contains valid information in this table, it is rewritten by the information from the level object properties. + +**LoopConfigEntry** (15 bytes) + + 0000 int16 Object Table Index + 0002 byte Loop type + 0003 [10]byte Unused + 000D int16 Frame time -- 0x0100 is 0.9 seconds; Default: 0x0080 + +**Loop Type** + + 0 Don't loop, stick with first frame + 1 Loop forward + 2 Play once (on finish, stick with last frame) + 3 Loop backward + 4 Loop back and forth + 5 Loop back and forth + 6 Loop back and forth + 7 Loop back and forth diff --git a/modding/ss-specs/archives/mapInformation.md b/modding/ss-specs/archives/mapInformation.md new file mode 100644 index 0000000..f913f9f --- /dev/null +++ b/modding/ss-specs/archives/mapInformation.md @@ -0,0 +1,252 @@ +## Map Information + +### Map layout + +#### Level information + +```L04```, a compressed resource, contains the basic information about a level (a map). + +> In the original source, this structure is called ```FullMap```. + +**Level Information** (58 bytes) + + 0000 sint32 X Size - always 64 in the archives. + 0004 sint32 Y Size - always 64 in the archives. + 0008 sint32 X Shift - always 6 in the archives. + 000C sint32 Y Shift - always 6 in the archives. + 0010 sint32 Z Shift + 0014 byte[4] Placeholder - ignore + 0018 byte Cyberspace flag. 0: real life, 1: cyberspace + 0019 byte[12] Unused + 0025 byte[21] Scheduler Info + +> The engine doesn't support maps with more than 4096 tiles. By default, this is a map of 64x64 tiles. +> While other combinations of X/Y sizes are feasible, as long as the respective shift parameter matches, there is +> hardly a point to try something different than 64x64. + +The ```z shift``` determines the height of the level as well as the size of one height unit for tiles. +The level is one tile width high (perfect cube) if the shift value is 5. +A shift value of 0 gives 32 tiles of height, a value of 7 one fourth (1/4) of a tile. So it's 32, 16, 8, 4, 2, 1, 1/2, 1/4 for 0..7. +> Most levels have a shift value of 3, giving 4 tile widths of height. Security (Level 8) has a shift value of 1 - 16 tiles. + +One height unit is the 32th of the level height. The higher the level is, the larger are the visible jumps of any height value. + +Although any level can be marked as cyberspace - even the starting level - only levels 10, 14 and 15 properly work. +This is due to the engine considering 10 and everything above 13 to be cyberspace. +Cyberspace items in other levels don't work as expected, especially the exit port either crashes the game or puts the player at the start of the cyberspace level again. + +As a further hardcoded limitation, only cyberspace levels 14 and 15 have a time limit. Level 10 is without limit. + +The ```placeholder``` has no use in the archives. In the engine, this field is used as a placeholder for a pointer within the game logic. + +The ```schedule``` information is the primary entry for level-specific timers. + + +**Level Scheduler Information** (21 bytes) + + 0000 sint32 Size - always 64 in the archives + 0004 sint32 Schedule count + 0008 sint32 Element size - always 8 in the archives + 000C byte Grow - always 0 in the archives + 000D byte[4] Pointer1 + 0011 byte[4] Pointer2 + + +```Size``` and ```element size``` require to be set to the values ```0x40``` and ```0x08``` respectively. Otherwise the level timers (stored in ```L06```) will not work properly. + +The ```element size``` of 8 comes from the size of scheduler entries. + +```Grow```, a boolean, would indicate to the scheduler system to allow reallocating the timer list to contain more than ```size``` amount of entries. Yet, the engine always initializes this with 0 (= don't grow). + +The two pointer fields are used engine-internally. + + +#### Tile map + +The tiles are described in the compressed resource ```L05```, a table of tile map entries. The first entry describes the lower left tile and the next entries go east first, then north (first columns, then rows). All levels have a size of 64x64 tiles as the engine supports (and expects) only this layout. + +The outermost tiles of the level (the 'border') should always be solid tiles. If these tiles are non-solid, the graphics engine may have rendering issues (walls not shown, just black) and entering these tiles freezes the game. This gives an effective available space of 62x62 tiles. + +**Tile Map Entry** (16 bytes) + + 0000 byte Type + 0001 byte Floor Info + 0002 byte Ceiling Info + 0003 byte Slope height. Valid for any non-flat tile. + 0004 sint16 Index to first object in cross-reference table. + 0006 uint16 Texture Info + 0008 uint32 Flags + 000C byte Sub Clip -- always 0xFF + 000D byte Clear Solid -- unknown + 000E byte Unused + 000F byte Light delta + +**Type** (1 byte) + +The type is an enumeration with the following constants: + + 0x00 solid + 0x01 open + 0x02 diagonal open s/e (nw edge closed off) + 0x03 diagonal open s/w (ne edge closed off) + 0x04 diagonal open n/w (se edge closed off) + 0x05 diagonal open n/e (sw edge closed off) + 0x06 slope s->n + 0x07 slope w->e + 0x08 slope n->s + 0x09 slope e->w + 0x0A valley se->nw + 0x0B valley sw->ne + 0x0C valley nw->se + 0x0D valley ne->sw + 0x0E ridge nw->se + 0x0F ridge ne->sw + 0x10 ridge se->nw + 0x11 ridge sw->ne + +All non-open tiles are given as low -> high. Valleys have 3 vertexes higher and one lower (at floor level). Ridges have one vertex higher and the remaining three at floor level. The given direction is that between the one vertex and its opposite one. + + +**Floor/Ceiling Info** (1 byte) + + bits 0-4 Height (ceiling is units down from 0x20) + bits 5-6 Texture rotation. When viewed from above: 1: 90°, 2: 180°, 3: 270° - always clockwise + bit 7 Hazard flag. Floor has biohazard, ceiling has radiation hazard. + +The exact behaviour for the hazard flags is determined by the [level variables](levelVariables.md). + +**Texture Info** (2 bytes) + +The texture info field contains information for the tile textures of the level. +For real world, it contains index values into the texture map (see next resource below). +Cyberspace levels specify the palette color index for floor and ceiling of the South-Western corner. + + Real World: + bits 0-5 Wall texture + bits 6-10 Ceiling texture + bits 11-15 Floor texture + + Cyberspace: + bits 0-7 Floor palette index + bits 8-15 Ceiling palette index + +**Flags** (4 bytes) + + Real World: + 0x80000000 Tile visited (automapper) + 0x70000000 Unused + 0x0F000000 Ceiling shadow + 0x00F00000 Unused + 0x000F0000 Floor shadow + 0x0000E000 Music index + 0x00001000 Music "peril" + 0x00000C00 Slope control. See below. + 0x00000200 Deconstructed flag ("spooky" music) + 0x00000100 Use wall texture from adjacent tile + 0x00000080 Unused -- only set for computer room on level 7 + 0x00000040 Flip wall texture horizontally alternating + 0x00000020 Flip wall texture horizontally + 0x0000001F Offset for wall textures + + Cyberspace: + 0x01000000 Flight pull: Strong pull towards floor + 0x000F0000 Flight pull + 0x0000E000 Music index + 0x00001000 Music "peril" + 0x00000C00 Slope control. See below. + 0x00000060 Game-Of-Life flags (either needs to be set) + + +```Ceiling shadow``` and ```Floor shadow``` specify the darkness intensity for the south-western corner of the tile. +The ceiling value darkens all four surrounding ceilings. +The walls made by surrounding top-pillars are darkened starting from the ceiling. +The walls made by surrounding bottom-pillars are darkened starting from their floor. + +The floor value darkens all four surrounding floors. +The walls made by surrounding bottom-pillars are darkened starting from the floor. +The walls made by surrounding top-pillars are darkened starting from their ceiling. + +> The actions to modify the light values can only give tiles more light, or remove previously added light. +> Thus, with shadow values of 0, these tiles are as bright as possible, without any chance of making them darker. + + +```Slope control``` specifies for the floor and ceiling how slopes (and valleys and ridges) should look like. + + 0: Ceiling is inverted to floor; A slope height of 0x1F will have ceiling and floor seal of the tile. + 1: Ceiling is mirroring the floor; A slope height of 0x10 will have their most extent vertexes touch each other. + 2: Ceiling is flat. + 3: Floor is flat. + + +```Flip wall texture horizontally``` + +Wall textures can be flipped horizontally. + +In the ```alternating``` case, flipping is based on the parities of the tile's two coordinates (even/odd status of the X and Y values). +Furthermore, North/South wall textures are flipped separately to East/West wall textures; If the North/South walls are flipped for a certain tile, the corresponding East/West walls will not be flipped. If the North/South walls are not flipped, then the East/West walls will be flipped. +> This ensures mirrored patterns are kept across corners without the need to change the flags for every other tile. This is heavily used on level 9 for the infested walls. + +The North/South texture is to be flipped if the parities of the tile's two coordinates are different - East/West would be normal in this case. + +The North/Shouth texture is normal if the parities of the tile's two coordinates match - East/West would be flipped in this case. + +This alternation is inverted if additionally bit ```0x00000020```` is set. + + +```Flight pull``` specifies what default translation/rotation forces are in play + + 0: None + 1: Weak pull towards East + 2: Weak pull towards West + 3: Weak pull towards North + 4: Weak pull towards South + 5: Medium pull towards East + 6: Medium pull towards West + 7: Medium pull towards North + 8: Medium pull towards South + 9: Strong pull towards East + 10: Strong pull towards West + 11: Strong pull towards North + 12: Strong pull towards South + 13: Medium pull towards ceiling + 14: Medium pull towards floor + 15: Strong pull towards ceiling + 16: Strong pull towards floor (requires separate extra bit) + +> The engine applies the force to the 'nose' of the hacker, i.e. it will pull the heading until the hacker looks straight +> at the origin of the force. This causes the implicit rotation. Only if the heading is exactly opposite to the origin, +> then no rotation happens (i.e., pulled backwards). +> All but the 'Strong' pulls can be countered. + +**Light delta** (1 byte) + +The high nibble specifies the light delta to the ceiling shadow. The low nibble specifies the light delta to the floor shadow. +The [Change lighting action](../levelObjects/Actions.md#action-type-7-change-lightning) modifies these values. + + +### Texture map + +This resource is a list of int16 texture identifier in resource ```L07``` with a fixed length of 108 bytes. +The ```Texture Info``` fields in the tile map refer to these identifier. +Although the field for wall textures would allow for 64 textures, the resource can only hold up to 54 textures. +The first 32 textures can be used for floor/ceiling as well. + +> The engine caches these textures when reloading a savegame with the same level. +> In the original source this list is also referred to as ```loaded textures``` or ```loved_textures```. +> The 'texture' index for space is 0x00CD, the observation grating has 0x00B1 (ceiling on groves and bridge) + +See [Level Textures](../content/LevelTextures.md) for further information. + + +### Map notes + +#### Text Data + +Text for the map notes is stored in the level-specific resource ```L46```. The notes are stored sequentially, with the 0x00 character terminating a note. + +The maximum length of one note is 40 bytes (including the termination character). The resource itself is pre-initialized with 0x00 bytes to a total length of 0x800 bytes. + +Data in this resource is always defragmented. If a note is modified or removed, the notes are rearranged. + +#### Next available space +Resource ```L47``` is a single uint32 offset, pointing to the first available byte in the text data resource. diff --git a/modding/ss-specs/archives/surveillanceCameras.md b/modding/ss-specs/archives/surveillanceCameras.md new file mode 100644 index 0000000..bfdafb7 --- /dev/null +++ b/modding/ss-specs/archives/surveillanceCameras.md @@ -0,0 +1,28 @@ +## Surveillance Cameras + +The engine supports up to 8 surveillance cameras per level. +Such a camera can be any object in the level, and an additional surrogate can determine when a linked screen should show static although the actual source would still be available. + + +### Surveillance Sources + +The sources of surveillance screens are defined with ```L43```. This resource contains a list of up to 8 object table index values. +These referred objects provide the location and orientation for the video source of a screen. A screen then refers to one of these entries by using one of the corresponding [special values as 'picture source'](../levelObjects/07_BigStuff/levelBigStuffEntry.md). + +**Surveillance Sources Table** (16 bytes) + + 0000 [8]int16 Source object table index + +> For a source that is not an actual camera object, it is typical to find 'null' trigger objects to be the reference. +> Unused surveillance sources refer to the unused object at index 0. + + +### Surveillance Surrogates + +Screens may be bound to a destructible object. If this surrogate is destroyed, the corresponding screen shows static. These associations are stored in ```L44```, a resource with a list of up to 8 object table index values. + +**Surveillance Surrogates Table** (16 bytes) + + 0000 [8]int16 Deathwatch object table index + +> For camera sources, it is typical to refer to the actual camera in this table. diff --git a/modding/ss-specs/archives/textureAnimation.md b/modding/ss-specs/archives/textureAnimation.md new file mode 100644 index 0000000..c9c82b8 --- /dev/null +++ b/modding/ss-specs/archives/textureAnimation.md @@ -0,0 +1,30 @@ +## Texture Animation + +```L42``` is a table containing animation information for textures. 4 entries are stored in this table. + +The ```Animation Group``` field of the [Texture Properties](../fileFormat/PropertyFiles#properties-entry) specifies +the index into this table. A wall that has such a texture set will use further textures. According to the +```Number of frames``` field of this table entry, more textures are used from the [Texture Map](mapInformation.md#texture-map). + +Meaning, if the level uses texture X which has an animation group set, the corresponding animation entry specifies +how many further textures are used for this animation. These then have all to be next to each other in the texture map. + +The properties of the first texture determine how the remaining will behave (climbable, ...) + +> Since most textures have their animation group set to 0x00 and have no animation, the first entry in this +> table is unused and the bytes always 0. + +**Animation Entry** (7 bytes) + + 0000 int16 Frame time (in milliseconds) + 0002 int16 Current frame time + 0004 byte Current frame index + 0005 byte Number of frames for loop + 0006 byte Loop type/Loop state + +**Loop Type** (1 byte) + + 0x00 Loop forward + 0x01 Loop forth-and-back (forward state) + 0x81 Loop forth-and-back (backward state) + diff --git a/modding/ss-specs/content/AccessLevels.md b/modding/ss-specs/content/AccessLevels.md new file mode 100644 index 0000000..adce60a --- /dev/null +++ b/modding/ss-specs/content/AccessLevels.md @@ -0,0 +1,9 @@ +## Access Levels + +Doors can be restricted to certain access levels. The engine knows about 32 access levels (0..31). + +The names of the access levels are stored as [texts](../media/Texts.md) in resource ```0x0879```. Per access level there +are two text strings, so 64 in total. The first string is the short name, for example ```STD``` and the second the long +name, ```Standard```. + +The first level (0) with name ```None``` is not checked. Doors with level 0 immediately open. diff --git a/modding/ss-specs/content/ElectronicMessages.md b/modding/ss-specs/content/ElectronicMessages.md new file mode 100644 index 0000000..3b16660 --- /dev/null +++ b/modding/ss-specs/content/ElectronicMessages.md @@ -0,0 +1,57 @@ +## Electronic Messages + +This chapter documents the electronic messages that the hacker receives either through triggers or collecting them. +This means e-mails (including video mails), logs and data fragments - everything the "Multimedia data reader" can show. + +### Text Format + +The [text resources](../media/Texts.md#electronic-messages) for the messages contain both the content, as well as the +meta information, encoded in text form. Any such message resource has the following string blocks: + + 1 line Meta info + 1 line Title + 1 line Sender + 1 line Subject + N lines Verbose text + 1 line Empty + M lines Terse text + 1 line Empty + +The ```Meta info``` line describes what to show additionally. This text has the following format: + + meta-info := [event " "] [color " "] [left-mfd] ["," [" "] right-mfd] + event := ("i" hex2) | "t" + color := "c" hex2 + left-mfd := dec + right-mfd := dec + + Examples: + "13, 18" + "i17 8" + "t cD1 23,23" + "cD1 23,6" + + +For most of the messages, at least ```left-mfd``` is specified. ```left-mfd``` and ```right-mfd``` are decimal offset +numbers that refer to the image to show in the respective MFD. The offset is based on resource ```0x0028```. +If ```left-mfd``` is greater or equal 256, then the message refers to a video mail. Video mails don't have an MFD image. + +If specified, the ```color``` field identifies a hexadecimal colour palette index. This colour is used to draw the sender and subject lines. + +The ```event``` field chains messages together. "i" events identify the message that must immediately follow the current one; +The hexadecimal number is the offset for the next message (based on resource ```0x0989```). Such interrupt messages then have "t" as event. + +> The vanilla resource files contain a few messages with errors in them: +> There are some "stub email" messages with empty header lines, the text "stub email" twice and only one terminating empty line. +> Furthermore, there is one message in ```gerstrng.res``` which misses the terminating empty line in the terse text. + +### Text Content + +Within the texts, the substring ```$N``` is the placeholder for the name of the hacker. + +### Audio + +Corresponding audio resources are based on ID ```0x0AB5```. If an electronic message with ID X has audio, the audio will have resource ID ```0x0AB5``` + X. (Also: resource ID of text resource + 300) + +These audio resources are [MOVI resources](../media/moviResources.md) with only the audio component. The audio recordings are stored in the files +```citalog.res``` (English), ```geralog.res``` (German) and ```frnalog.res``` (French). diff --git a/modding/ss-specs/content/LevelTextures.md b/modding/ss-specs/content/LevelTextures.md new file mode 100644 index 0000000..f96693c --- /dev/null +++ b/modding/ss-specs/content/LevelTextures.md @@ -0,0 +1,29 @@ +## Level Textures + +A level texture is a square bitmap used for level paneling - floors, ceiling and walls. The resources contain 273 textures, +for which there are four different resolutions available. + +### Bitmaps + +The bitmaps are stored uncompressed in ```texture.res``` in the following resources: + + 0x004C 16x16 bitmaps, one per block (a compound resource) + 0x004D 32x32 bitmaps, one per block (a compound resource) + 0x02C3 - 0x03D3 64x64 bitmaps, one per resource + 0x03E8 - 0x04F8 128x128 bitmaps, one per resource + +### Texts + +Each texture comes with a description (name) and a "can't be used" message. [These texts](../media/Texts.md) +are stored in resources ```0x086A``` and ```0x086B``` respectively. + +> Note: The text resources reserve 512 blocks, but only 273 are used. + +### Properties + +The [texture properties](../fileFormat/PropertyFiles#texture-Properties-textprop.dat) give further information on textures. + +### Animation + +Some textures, according to their properties, are in animation groups. These groups are controlled [per level](../archives/textureAnimation.md). + diff --git a/modding/ss-specs/conventions.md b/modding/ss-specs/conventions.md new file mode 100644 index 0000000..cea16fc --- /dev/null +++ b/modding/ss-specs/conventions.md @@ -0,0 +1,72 @@ +## Conventions + +### Content +Information in these documents MUST be verifiable, or have been verified (e.g. through testing or cross-reference to original code) to be included. Assumptions, contradictions, discussions and the like MUST be handled through the [issue](https://github.com/inkyblackness/ss-specs/issues) system. + +In general, changes to the documents SHOULD be introduced by linking commits to corresponding issues. This way, issues can be used as reference material for verification. + +### Language +The human language of this documentation is English. Names of types/fields SHOULD follow that of the source (which is presumably en-us), while prose text SHOULD follow en-uk . + +This is primarily to uphold the legacy of this documentation. The original ss-specs was maintained within the context of TSSHP, which came from Britain. + +When wording actions, consider differenciating between the character in the game-world ("the hacker"), and the player. ("The hacker has a rotation angle of xyz, so the player sees..."). + +Sentences referring to individuals SHALL be phrased with inclusive, gender neutral language. The use of [singular they](https://en.wikipedia.org/wiki/Singular_they) is to be used if no other option is available. + +> While the hacker is represented with a male body and voice in the original game, a fan-mod could change this. (see [here](https://www.moddb.com/mods/system-shock-female-hacker)) +> This documentation states raw engine data, which doesn't care about humans, even if considered to be insects. + +More generally, consider online guides about inclusive language, such as [this one from Google](https://developers.google.com/style/inclusive-documentation). + +### Formatting + +Vanilla [Markdown](http://daringfireball.net/projects/markdown/syntax) SHOULD be used for all pages. + +The biggest header available is the H2 header (""## Title"). H1 header are reserved for the main title. Each page SHOULD start with an H2 header, which is also the link in the [index](index.md). + + +### Data +In general, this documentation describes (serialized) data structures. Such data structures SHOULD be documented using code blocks and follow the following format: + + offset type description + + +#### Example + + 0000 int32 some value + 0004 [14]byte unknown, not used (0x00) + 0012 int16 other value + +Offset values MUST start at 0 and be given in hexadecimal (or placeholder). They SHOULD have a width of 4 (or more) digits. + +Types are field specific. + +Description is a short human-readable text describing the field. + +#### Integers + +For integer types, they MUST be given in the following format: + + (s|u)?int(8|16|24|32) + +'s' and 'u' prefixes specify 'signed' and 'unsigned integer' respectively. If no sign-prefix is given, it has not been verified whether this field allows negative values or allows greater positive values. + +Implementors MAY treat fields without known sign to be unsigned - this can be the case for length fields for instance. It is unknown whether the original executable would handle them correctly. + +Unless otherwise noted, integer values are stored in [little-endian](http://en.wikipedia.org/wiki/Endianness#Little-endian) format. + +#### Fixed point numbers + +Due hardware limitations and slow perfomance of float point operations, System Shock uses fixed point number represenation of real data type. +Usually engine uses [Q15.16](https://en.wikipedia.org/wiki/Q_(number_format)) format (1 bit for sign, 15 bits for integer part, 16 bits for fractional): + + 0x 0000 0000 + | | + | fractional part + integer part with sign (first bit, 0 means positive, 1 - negative) + +This format is refered as `fix` (int32). For example, (2 * Pi) number is `0x0006487f` (6 + 18559/65536 = 6.28319) + +Internally engine supports Q23.8 numbers (`fix24`) or even Q2.28 (for some 2D- and 3D-computations). + diff --git a/modding/ss-specs/fileFormat/PropertyFiles.md b/modding/ss-specs/fileFormat/PropertyFiles.md new file mode 100644 index 0000000..aeb5bf0 --- /dev/null +++ b/modding/ss-specs/fileFormat/PropertyFiles.md @@ -0,0 +1,196 @@ +## Property Files +A few files don't have an archive format, and instead are flat files consisting directly serialized tables. + +### Texture Properties (textprop.dat) +This file contains a single table of texture property entries, after a header. + +> The table appears to be truncated. Since the game only uses 273 textures, this may have never been detected. +> The file has 4004 bytes on disk, meaning 4000 bytes are available for the table. +> In a test, the original engine appeared to be fine to read a smaller file with exactly 273 entries (3007 bytes) - +> which had the unknown fields set to zero. + +> The original source explains the difference: The texture property system would support up to 400 textures. +> It also **assumed** a property structure size of 10 bytes (hardcoded value), while the actual structure is 11 bytes long. +> In other words, one could feed the engine more than 273 textures, yet no more than 363. More than 363 would simply be ignored and the extra textures would just have their default properties. +> This also explains why the Mac port reads exactly 363 textures, as reading more would exhaust the file. + + +#### Header +The file starts with a 4-byte header. + +**Texture Properties File Header** (4 bytes) + + 0000 int32 file version number, value 0x09 + + +The engine rejects a properties file with a different version number. + + +#### Properties Entry + +**Texture Property Entry** (11 bytes) + + 0000 byte family (unused) + 0001 byte target (unused) + 0002 sint16 resilience (unused); Default: 10 + 0004 sint16 distance modifier; Default: 0 + 0006 byte Climbable. 0x00: not climbable; != 0x00: wall can be climbed (original name: friction) + 0007 byte friction walk (unused); Always 0x00 + 0008 byte Transparency control (original name: force_dir) + 0009 byte Animation group + 000A byte Animation index - if texture is animated + +The two bytes at the beginning (`family` and `target`) almost always are each set to the low-byte of the index. A few exceptions have both bytes 0x00. Since they are unused, it is unclear what they mean. + +The `resilience` is nearly always `0x0A`; The one existing exception has it 0. Since it is unused, it is unclear what it means. + +The `distance modifier` always zero. It is used in the renderer to determine which texture resolution to use, based on distance from the camera. A higher modifier causes the smaller resolutions to be chosen at larger distances; A negative value causes an earlier switch. + +See [Texture Animation](../archives/textureAnimation.md) for details on animated textures. + +**Transparency Control** + + 0x00 Regular texture; transparent pixel (palette index 0x00) are drawn black + 0x01 Space texture (pixel data of image is ignored) + 0x02 Texture with space background for transparent pixels + + +### Object Properties (objprop.dat) +The object properties file contains several tables about properties of [level objects](../levelObjects/index.md). It has the following format: + + | Header | Class 0 Tables | Class 1 Tables | ... | Common Table | + +#### Header +The file starts with a 4-byte header containing the version number. + +**Object Properties File Header** (4 bytes) + + 0000 int32 file version number, value 0x2D + + +The engine rejects a properties file with a different version number. + +#### Class Tables +For each object class, one entry exists with a table of class-generic properties and a further table, subclass-specific: + + | Class N Tables | + | Generic | Subclass 0 | Subclass 1 | ... | Subclass N | + +The sizes of the generic and specific tables are dependent on the object class, subclass and type amount. +The generic table contains one entry per object type of this class. The specific tables contain one entry per object type of the subclass. + +See [Level Objects](../levelObjects/index.md) for an overview of the amount of types and their entry sizes. + + +#### Common Table +At the end of the file is the table with common properties. For each object type one entry. + +**Common Object Properties** (27 bytes) + + 0000 int32 Mass + 0004 int16 Default hitpoints + 0006 uint8 Armour + 0007 byte Render type + 0008 byte Physics type; 0x00: none (insubstantial), 0x01: regular, 0x02: special + 0009 byte Hardness; 0..253 + 000A byte Unused; Internally called "pep", this value appears to not be used by the engine. + 000B byte Physics XR; 0..253 + 000C byte Unused; Internally called "physics_y". These values are set (almost) identical to 000B and have no effect. + 000D byte Physics Z; Set to 0x30 for all barriers (10/x/x). + 000E byte Vulnerabilities + 000F byte Special vulnerabilities + 0010 [2]byte Unused + 0012 byte Defence value + 0013 byte Toughness; 0x03: Invincible + 0014 int16 Flags + 0016 int16 MFD/Mesh ID + 0018 [2]byte Bitmap 3D field model index + 001A byte Destruction effect + + +`Physics type` governs how collisions shall be handled. `0x00` makes the object insubstantial, ignoring forces applied to it. For instance, most scenery stays where it was put. `0x01` is for regular objects meant to react "normal" in a physical world. `0x02` behaves strange is set for exactly one object (`14/0/6`, "SONIC THE HEDGEHOG", an unused critter). Objects with a physics type of `0x02` aren't affected by gravity for instance, but are repelled if hacker runs into them. +The `hardness` acts as repulsion force on the object if it collides with the ground or walls - or on the hacker if hacker runs into it. +Hardness depends on the `mass`; Objects with more mass bounce around less. + +The `physics xr` value is applied to vertically center (animated) sprites on their position. If `0x00`, the sprites are rendered with their bottom anchored at the object's position. Higher values will move the sprites downwards. + +Related, the `physics z` value is inconsistently used as a height value. Only if this value is `0x00` (most of the time), then `physics xr` is used for height calculation. Still, the XR value is used far more often than the Z value. + +The `vulnerabilities` and `special vulnerabilities` match the corresponding damage type fields from [Generic Weapon Info](../levelObjects/GenericWeaponInfo.md). +> The engine, internally, calls this field `resistances`, yet the code does the opposite. Furthermore, this `resistances` field covers the four bytes from `000E`. + +The field of `special vulnerabilities` is split up into `0x0F` primary vulnerabilities (double damage) and `0xF0` super vulnerabilities (quad damage). Damage is multiplied only if the originating special force equals the vulnerabilities in these fields. +> Quad damage is reduced to triple damage on difficulty level 3 (either for real-world, or cyber, wherever it is calculated). + +The `defence value` is compared to the `offence value` of a weapon or projectile to determine a "critical hit" (or critical reduction). Higher defence values will decrease the damage, higher offence values will increase the damage. If it is `0xFF`, then the target is invulnerable to critical hits. + +The `toughness` determines how much damage, after most of the calculation, goes through - this still requires the object to be solid to register damage. The value of `0x03` reduces the damage to zero. Otherwise, this value is considered to be a shift factor, so a value of `0x01` halves damage, `0x02` quarters it. +> This value is set to `0x04` only for the non-used SHODAN critter `14/3/5`. This one would have been affected by only the strongest weapons. +> Probably this value was intended to be in range of 0..3 only. + +`MFD/Mesh ID` identifies the MFD to open for this type of object, if selected from inventory. For 3D objects in the world, this value identifies the 3D mesh to render, based on resource `0x08FC`. + + +**Render Type Enumeration** (1 byte) + + 0x01 3D object + 0x02 Sprite + 0x03 Screen + 0x04 Critter + 0x06 Fragments (e.g. the Cyberdog) + 0x07 Not drawn + 0x08 Oriented surface (door, wall decoration) + 0x0B Special + 0x0C Force door + + +**Common Object Flags** (2 byte) + + 0x0001 Useful inventory object (green font) + 0x0002 Solid - can't be passed through + 0x000C Use mode (see below) + 0x0010 Usable inventory object - object is triggered on double-click in inventory list + 0x0020 Blocks 3D rendering if closed (set for opaque doors) + 0x00C0 Light type (see below) + 0x0100 Solid if closed (set for barriers and repulsor object) + 0x0200 Flat solid (objects can be placed on it) + 0x0400 Doubled bitmap size (set for various explosion animations) + 0x0800 Destroy on contact + 0x7000 Class-specific flags + 0x8000 Useless object. Unless specifically marked useful otherwise, object can be removed to make room + +**Use Mode Enumeration** (2 bit) + + 0x00 Pickup - items can be taken + 0x01 Use - items are used, not taken + 0x02 undefined + 0x03 "Null" - can't be used + +**Light Type Enumeration** (2 bit) + + 0x00 Normal, simple lighting + 0x01 Complex lighting + 0x02 Ignore darkness + 0x03 Deferred light type (instance-specific) + + +**Bitmap 3D Bitfield** (2 byte) + + 0x03FF Bitmap number low mask + 0x8000 Bitmap number high mask + 0x0400 Animation + 0x0800 Repeat + 0x7000 Frame number mask + + +**Object Destruction Effect** (1 byte) + +This byte is a bitmask field with the following layout: + + 0x1F Animation frame index -- refers to different types of animations + 0x20 Play destruction sound + 0x40 Play destruction sound (not used in main game) + 0x80 Actual explosion (with damage) -- requires Animation frame index to be 0x0F + +> Some objects have their "private" destruction sounds and have none of the sound bits set. +> The `actual explosion`, set for larger electrical appliances, works only if the full value is `0x8F`. diff --git a/modding/ss-specs/fileFormat/ResourceFiles.md b/modding/ss-specs/fileFormat/ResourceFiles.md new file mode 100644 index 0000000..93720f5 --- /dev/null +++ b/modding/ss-specs/fileFormat/ResourceFiles.md @@ -0,0 +1,126 @@ +## Resource Files +Resource files are structured archives. These files contain one or more "resource" entries. Resources are identified and contain one or more data blocks. A resource with more than one data block is called a "compound resource". + +### Identifier + +A resource identifier is a ```uint16``` value, and a compound resource uses a ```uint16``` value as index. + +Resource identifier are "valid" from value ```0x0003``` and above. +```0x0000``` is the NULL identifier (this was quick approach to erase resources from file for developers), and ```0x0001``` and ```0x0002``` are used engine-internally (first used to point to head ptr in +[LRU](https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU)) cache, second - to tail ptr in LRU). + +### General Format +A resource file consists of a header, resources and a file directory. The header points to the file offset of the file directory and the file directory points to the file offset of the first resource. + + | /--------------------------------------v | + | Header.dirOffset | Res0 | Res1 | ... | Res N | Directory.dataOffset | + | ^----------------------------------------/ | + +All resources, as well as the file directory, start at a 4-byte boundary. + +To calculate the file offset of a resource, the file directory has to be read first to determine the offsets (and lengths) of the previous resources. + +> Although the format would allow for a different order, all resource files have the directory at the end of the file. + +### File Header +The file header has a length of 128 bytes and has the following format: + +**File Header** (`ResFileHeader` struct, 128 bytes) + + 0000 [16]byte signature signature "LG Res File v2\r\n" + 0010 [96]byte comment comment, terminated with "\x1A" (remainder 0x00) + 0070 [12]byte reserved reserved, set to 0x00 + 007C sint32 dirOffset file offset to directory + +> No resource file has been found containing a comment. It always only contains the terminator byte 0x1A. +> The comment is terminated with 0x1A as this represents the ```Ctrl+Z``` code. This was a little trick to enable people to ```type``` the file on the command line and not be spammed with binary garbage. + +### File Directory +The file directory consists of a 6 byte header followed directly by its directory entries: + +**Resource file directory header** (`ResDirHeader` struct, 6 bytes) + + 0000 uint16 numEntries number of resources in the file + 0002 sint32 dataOffset file offset to beginning of first resource + +**Resource file directory entry** (`ResDirEntry` struct, 10 bytes) + + 0000 uint16 id resource ID + 0002 sint24 size resource length (unpacked) + 0005 sint8 flags resource flags (see below) + 0006 sint24 csize resource length (packed in file) + 0009 sint8 type resource type (see below) + +### Resource Flags + +The resource flag marks extra information about the resource. + +**Resource Flag** (1 byte) + + 0x01 RDF_LZW compressed + 0x02 RDF_COMPOUND compound + 0x04 RDF_RESERVED reserved (unused) + 0x08 RDF_LOADONOPEN load on open (unused) + +> The original source lists several more resource flags, which are all unused in System Shock. +> Presumably this is the result of re-used code. + +"Flat" resources contain only one data block, which is the complete resource data itself. Compound resources contain 0, one, or more blocks. + +### Resource Types + +The following resource types appears in System Shock: + + 0x00 UNKNOWN Palette (although originally called "unknown", it is used for palettes exclusively) + 0x01 STRING String + 0x02 IMAGE Image + 0x03 FONT Font + 0x04 ANIM Animation Script (Video clip) + 0x07 VOC Sound effects and voices (.voc) + 0x0F OBJ3D 3D model geometry + 0x11 MOVIE Movie + 0x30 APP1 Application specific resource (used in archive.dat for storing maps) + +> The original source lists several more resource types, which are all unused in System Shock. +> Presumably this is the result of re-used code. +> +> There exists 0x05 (PALL) resource type specially for palettes, but never used in System Shock. + +### Compound Resources +If a resource is flagged to be compound, the resource data starts with a directory. This directory contains relative offsets to the first bytes of the contained blocks, following the directory. + +**Resource directory** (2 bytes + 4 bytes per block + 4 bytes) + + 0000 uint16 number of blocks in resource + 0002 sint32 resource offset to first block + .... + nnnn sint32 resource offset to last block + nnnn+4 sint32 total length of resource + +To calculate the length of a block N, take the start offset of N+1 and subtract the start offset of N. This is why there is one extra offset entry in the directory. + +If a compound resource is compressed, the directory itself is not compressed. Resource offsets always point into the uncompressed data. + +> Some compound resources have two zero bytes between the directory and the first block. + +### Resource Compression +Resource compression is based on the [LZW algorithm](http://en.wikipedia.org/wiki/Lempel%E2%80%93Ziv%E2%80%93Welch). It uses a fixed word-size of 14 bits. Two words are reserved for control. + +The words are serialized sequentially big-endian as a continuous stream. +For example, if the first 7 bytes in the resource are + + aaaaaaaa bbbbbbbb cccccccc dddddddd eeeeeeee ffffffff gggggggg + +then the corresponding words are + + aaaaaaaabbbbbb bbccccccccdddd ddddeeeeeeeeff ffffffgggggggg + +with the high bit to the left in each case. + +The first 256 words of the dictionary are initialized with length 1 for the corresponding byte values. The reserved words are: +* 0x3FFE Reset dictionary to initial state +* 0x3FFF End-Of-Stream + +The End-Of-Stream marker is always issued. The serialized byte stream always contains a trailing 0x00 byte after the End-Of-Stream word. + +The compressor issues a dictionary reset when it is exhausting the dictionary and can't add a new word to it for the 1001st time. diff --git a/modding/ss-specs/fileFormat/index.md b/modding/ss-specs/fileFormat/index.md new file mode 100644 index 0000000..403f475 --- /dev/null +++ b/modding/ss-specs/fileFormat/index.md @@ -0,0 +1,188 @@ +## File Overview +### Variants +Not considering any re-releases, System Shock 1 was available in the following variants: +* For DOS + * Demo: HD and CD + * Release: HD and CD +* For MAC + * Release: CD + +> Unless otherwise noted, this documentation considers only the DOS CD release. + +The main difference between HD and CD releases is the CD releases contain audio files for the voice logs and provide a high resolution option. + +### Locations +Data files described in this documentation are available at the following locations: +* CD-ROM (System Shock 1 CD) + * /cdrom/data + * /hd/data (will be copied to HD) +* HD (System Shock installation directory) + * /data + +### File Types +Data files come in the following variants: +* [Resource files](ResourceFiles.md), which are structured archives +* [Property files](PropertyFiles.md), containing one or more serialized tables + +Resource files are the majority. All files with filename suffix `.res` are resource files. Savegame files have the same format, including the "starter" savegame file `archive.dat`. These files contain one or more "resource" entries. Resources are identified and contain one or more data blocks. A resource with more than one data block is called a "compound resource". + +The property files are "flat" files containing one or more serialized tables. + +### List of Files + +The following table shows an overview of the known files, across the different releases. + +For resource files, resources may share the same identifier. In this case they represent the alternatives available through configuration. The options are: +* Language. Available are: "English", "French" and "German" +* Cutscene resolution (MOVI format). Available are: "low" (320x150) and "svga" (600x300) + +> The "bitmap" based cutscenes have a resolution of 320x200 and have their own identifier. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ContentLanguagesLocation HD ReleaseLocation CD Release
ENGFRNGERDemoReleaseDemoRelease
Startup and Cutscenes
Splash screens```splash.res```HDHDCDCD
Splash screen palette```splshpal.res```--HD--CD
Palette for low-res bitmap cutscenes```cutspal.res```--HD--CD
Low-Res bitmap intro cutscene```start1.res```--HD--CD
Low-Res MOVI intro cutscene```lowintr.res``````lofrintr.res``````logeintr.res```------CD
High-Res MOVI intro cutscene```svgaintr.res``````svfrintr.res``````svgeintr.res```------CD
Low-Res bitmap end cutscene```win1.res```--HD--CD
Low-Res MOVI end cutscene```lowend.res```------CD
High-Res MOVI end cutscene```svgaend.res```------CD
Low-Res bitmap death cutscene```death.res```--HD--CD
Low-Res MOVI death cutscene```lowdeth.res```------CD
High-Res MOVI death cutscene```svgadeth.res```------CD
Main menu screens```intro.res```HDHDCDHD & CD
In-game Resources
New Game Template```archive.dat```HDHDCDCD
In-game palette```gamepal.res```HDHDCDCD
Texts```cybstrng.res``````frnstrng.res``````gerstrng.res```HDHDHDHD
HUD resources```mfdart.res``````mfdfrn.res``````mfdger.res```HD 1)HDHD 1)HD
Trigger audio```citbark.res``````frnbark.res``````gerbark.res```----CD 1)CD
Log Audio```citalog.res``````frnalog.res``````geralog.res```----CD 1)CD
Video Mails```vidmail.res```--HD--CD
???```objart.res```HDHDCDCD
???```objart2.res```HDHDHDHD
???```sideart.res```HDHDHDHD
Level textures```texture.res```HDHDHDHD
???```citmat.res```HDHDHDHD
Sound Effects```digifx.res```HDHDHDHD
???```gamescr.res```HDHDHDHD
???```handart.res```HDHDHDHD
???```obj3d.res```HDHDHDHD
???```objart3.res```HDHDCDHD
Property Files
Object properties```objprop.dat```HDHDCDHD
Texture properties```textprop.dat```HDHDCDCD
+1) Only the English files available diff --git a/modding/ss-specs/index.md b/modding/ss-specs/index.md new file mode 100644 index 0000000..12424a5 --- /dev/null +++ b/modding/ss-specs/index.md @@ -0,0 +1,29 @@ +## Index + +* [Conventions](conventions.md) + +* General File Format + * [File Overview](fileFormat/index.md) + * [Resource Files](fileFormat/ResourceFiles.md) + * [Property Files](fileFormat/PropertyFiles.md) + +* Archives + * [Archive Overview](archives/index.md) + +* Raw Media + * [Bitmaps](media/Bitmaps.md) + * [Palettes](media/Palettes.md) + * [Sound Effects](media/SoundEffects.md) + * [MOVI Resources](media/moviResources.md) + * [Video Clips](media/VideoClips.md) + * [Texts](media/Texts.md) + * [Geometry](media/Geometry.md) + +* Content + * [Access Levels](content/AccessLevels.md) + * [Electronic Messages](content/ElectronicMessages.md) + * [Level Objects](levelObjects/index.md) + * [Level Textures](content/LevelTextures.md) + +* Other Files + * [Music](other/Music.md) diff --git a/modding/ss-specs/levelObjects/00_Gun/gunProperties.md b/modding/ss-specs/levelObjects/00_Gun/gunProperties.md new file mode 100644 index 0000000..7db4bbd --- /dev/null +++ b/modding/ss-specs/levelObjects/00_Gun/gunProperties.md @@ -0,0 +1,79 @@ +### Gun Properties + +#### Generic Gun Properties + +**Generic Gun Propertes** (`GunProp` struct) (2 bytes) + + 0000 uint8 Fire rate + 0001 byte Ammo type + +For non-automatic guns, the `fire rate` specifies when the next bullet can be shot. For melee weapons it is the time until the next swing can be made. +It is measured in game time, with a unit for this value of about 36 milliseconds. A value of ```0x00``` means 'as fast as you can press fire'. +> Changing this value does not have any detectable effect for automatic guns (```0/1/x```). + + +**Ammo type** (1 byte) + + 0xF0 Ammo subclass identifier + 0x0F Bitmask, which ammo types within the ammo subclass are compatible (0..3) + +> Ammo type is ignored for beam and melee weapons. + + +#### Specific 2 Properties + +This subclass describes special projectile weapons. + +**Specific 2 Properties** (`SpecialGunProp` struct) (16 bytes) + + 0000 [8]byte [Generic Weapon Info](../GenericWeaponInfo.md) + 0008 uint8 Projectile travel speed + 0009 int32 Projectile triple (0x00CCSSTT) + 000D uint8 Attack mass + 000E sint16 Attack speed (kickback) + + +#### Specific 3 Properties + +This subclass describes melee weapons. + +**Specific 3 Properties** (`HandtohandGunProp` struct) (13 bytes) + + 0000 [8]byte [Generic Weapon Info](../GenericWeaponInfo.md) + 0008 uint8 Energy usage + 0009 uint8 Attack mass (impact force - pushback on hit) + 000A uint8 Range + 000B sint16 Attack speed (kickback; only applied if hitting something) + + +#### Specific 4 Properties + +This subclass describes energy beam weapons. + +**Specific 4 Properties** (`BeamGunProp` struct) (13 bytes) + + 0000 [8]byte [Generic Weapon Info](../GenericWeaponInfo.md) + 0008 uint8 Max charge + 0009 uint8 Attack mass (impact force - pushback on hit) + 000A uint8 Range + 000B sint16 Attack speed (kickback) + + +#### Specific 5 Properties + +This subclass describes energy projectile weapons. + +**Specific 5 Properties** (`BeamprojGunProp` struct) (18 bytes) + + 0000 [8]byte [Generic Weapon Info](../GenericWeaponInfo.md) + 0008 uint8 Max charge + 0009 byte Attack mass + 000A sint16 Attack speed (kickback) + 000C uint8 Projectile travel speed + 000D int32 Projectile triple (0x00CCSSTT) + 0011 byte Flags + + +**Energy Projectile Weapon Flags** (1 byte) + + 0x02 Stun effect diff --git a/modding/ss-specs/levelObjects/00_Gun/levelGunEntry.md b/modding/ss-specs/levelObjects/00_Gun/levelGunEntry.md new file mode 100644 index 0000000..84c8062 --- /dev/null +++ b/modding/ss-specs/levelObjects/00_Gun/levelGunEntry.md @@ -0,0 +1,9 @@ +### Level Gun Table (Class 0) + +```L10``` is a table describing weapons and entries extend [the prefix](../../archives/levelObjects.md#class-specific-information) with their current ammo or charge. + +**Level Gun Entry** (8 bytes) + + 0000 [6]byte Level object prefix + 0006 byte Ammo type / Charge + 0007 byte Ammo count / Temperature diff --git a/modding/ss-specs/levelObjects/01_Ammo/ammoProperties.md b/modding/ss-specs/levelObjects/01_Ammo/ammoProperties.md new file mode 100644 index 0000000..7ac3bbb --- /dev/null +++ b/modding/ss-specs/levelObjects/01_Ammo/ammoProperties.md @@ -0,0 +1,12 @@ +### Ammo Properties + +#### Generic Ammo Properties + +**Generic Ammo Propertes** (`AmmoProp` struct) (14 bytes) + + 0000 [8]byte [Generic Weapon Info](../GenericWeaponInfo.md) + 0008 uint8 Cartridge size + 0009 uint8 Bullet mass (impact force - pushback on hit) + 000A sint16 Bullet speed (kickback) + 000C uint8 Range + 000D uint8 Recoil force diff --git a/modding/ss-specs/levelObjects/01_Ammo/levelAmmoEntry.md b/modding/ss-specs/levelObjects/01_Ammo/levelAmmoEntry.md new file mode 100644 index 0000000..5ca63a1 --- /dev/null +++ b/modding/ss-specs/levelObjects/01_Ammo/levelAmmoEntry.md @@ -0,0 +1,7 @@ +### Level Ammo Table (Class 1) + +```L11``` is a table for ammo clips. Since clips are always full, entries have no further fields. + +**Level Ammo Entry** (6 bytes) + + 0000 [6]byte Level object prefix diff --git a/modding/ss-specs/levelObjects/02_Physics/physicsProperties.md b/modding/ss-specs/levelObjects/02_Physics/physicsProperties.md new file mode 100644 index 0000000..f488cff --- /dev/null +++ b/modding/ss-specs/levelObjects/02_Physics/physicsProperties.md @@ -0,0 +1,42 @@ +### Physics Properties + +#### Generic Properties + +**Physics Generic Properties** (`PhysicsProp` struct) (1 byte) + + 0000 byte Physics flags + + +**Physics Flags** (1 byte) + + 0x01 Emit light + 0x02 Bounce off walls + 0x04 Bounce off objects (real world), Pass through objects (cyberspace) + 0x08 Bounce off projectiles; set for all cyberspace projectiles + + +> Cyberspace projectiles can't emit light, though they can bounce off walls. + + +#### Specific 0 Properties + +"Tracer" objects. + +> These are not used in the engine. + +**Physics Specific 0 Properties** (`TracerPhysicsProp` struct) (20 bytes) + + 0000 [4]int16 X coordinates + 0008 [4]int16 Y coordinates + 0010 [4]uint8 Z coordinates + + +#### Specific 1 Properties + +These properties are set for some `2/1/x` objects. More specifically: For cyberspace projectiles, also called "slow physics". The other projectiles of this subclass have their specific bytes zero. + +**Physics Specific 1 Properties** (`SlowPhysicsProp` struct) (6 bytes) + + 0000 [6]byte Colour scheme + +`Colour scheme` contains 6 palette indices from the game palette. The index into this list comes from the geometry properties, specifically from the [Set Colour and Shade](../../media/Geometry#set-colour-and-shade) command. diff --git a/modding/ss-specs/levelObjects/03_Grenade/grenadeProperties.md b/modding/ss-specs/levelObjects/03_Grenade/grenadeProperties.md new file mode 100644 index 0000000..567667e --- /dev/null +++ b/modding/ss-specs/levelObjects/03_Grenade/grenadeProperties.md @@ -0,0 +1,50 @@ +### Grenade Properties + +#### Generic Grenade Properties + +**Generic Grenade Propertes** (`GrenadeProp` struct) (15 bytes) + + 0000 [8]byte [Generic Weapon Info](../GenericWeaponInfo.md) + 0008 byte Touchiness + 0009 byte Blast radius + 000A byte Blast core range + 000B byte Blast damage + 000C uint8 Attack mass (impact force - pushback on hit) + 000D int16 Grenade Flags + +`Touchiness` defines how likely a grenade is going to chain-explode if another was exploding nearby. +> This value influences a calculation, based on a random value, as well as damage percentage. If the resulting number is higher than 7, then a chain-reaction is caused. +> In the main game, this value is always `0x00`. + +`Blast radius` specifies in tiles how far the explosion reaches, including visual effects (EMP scroll, explosion flash). + +The main damage (from the `Generic Weapon Info`) is applied within the `Blast core range` of the explosion. This damage falls off from the center to the edge of the core range. + +The `blast damage` is fully applied within the `blast core range`, additionally to the main damage. This damage falls off from the edge of the core range to the edge of the blast radius. + + +**Grenade Flags** (2 bytes) + + 0x01 Explode on contact + 0x02 unused + 0x04 Explode by timer + 0x08 Explode by vicinity (mine) + +> The behaviour of grenades is not exclusively governed by the flags field. Some seems to be hardcoded. +> For instance, regular grenades marked with "timer" don't offer a time setting in the HUD and explode right after being thrown. +> Regular timed grenades marked as "contact" ignore their timer setting, explode on object-contact, but not on floor/wall contact. + + +#### Specific 1 Properties + +Properties for timed grenades. + +**Specific 1 Properties** (`TimedGrenadeProp` struct) (3 bytes) + + 0000 uint8 Minimum time + 0001 uint8 Maximum time + 0002 uint8 Timing deviation -- always 0x00 + +`Minimum time` and `maximum time` specify the range, in seconds, timed grenades can be set to. + +> `Timing deviation` is always set to 0x00. Since it may also let the grenade go off earlier than expected, this may happen while still in the hand - which is presumably why it is unused in the main game. diff --git a/modding/ss-specs/levelObjects/03_Grenade/levelGrenadeEntry.md b/modding/ss-specs/levelObjects/03_Grenade/levelGrenadeEntry.md new file mode 100644 index 0000000..c86c80e --- /dev/null +++ b/modding/ss-specs/levelObjects/03_Grenade/levelGrenadeEntry.md @@ -0,0 +1,44 @@ +## Level Explosive Table (Class 3) + +```L13``` is a table describing explosives. + +**Level Explosive Entry** (12 bytes) + + 0000 [6]byte Level object prefix + 0006 [6]byte Explosive data + +**Explosive Data** (6 bytes) + + 0000 int16 Unknown; Only set for live explosives + 0002 int16 State + 0004 int16 Timer time + + +**State** (2 bytes) + + 0x0000 Inert + 0x0001 Thrown live grenade + 0x0005 Landed live grenade (land mines only, can't be picked up) + + +The timer settings of explosives are stored in the [game state](../../archives/gameState.md). + +The ```Timer time``` field is set to the same value as in the [level schedules](../../archives/levelSchedules.md), though modifying this value doesn't have an effect. The level timer is the authorative entry when to trigger the explosive. + + +### Contact explosives 3/0/x + +Contact explosives start a timer, though its expiry does nothing with the grenade. +Land mines can be catched mid-air and re-thrown, others explode. Land mines enter state ```0x0005``` when touching the ground, after which they can't be picked up safely anymore. + +> Although the game state also keeps time settings for these explosives, they can't be changed. Their timer don't have an effect anyway. + + +### Timed explosives 3/1/0, 3/1/1 + +These explosives keep the state at ```0x0001```, even when landed. Live explosives can be picked up and re-thrown, until their timer sets them off. + + +### Object explosion 3/1/2 + +This item has not been encountered. diff --git a/modding/ss-specs/levelObjects/04_Drug/levelDrugEntry.md b/modding/ss-specs/levelObjects/04_Drug/levelDrugEntry.md new file mode 100644 index 0000000..6c0fdcf --- /dev/null +++ b/modding/ss-specs/levelObjects/04_Drug/levelDrugEntry.md @@ -0,0 +1,9 @@ +### Level Drug Table (Class 4) + +```L14``` is a table describing Drugs. + +**Level Drug Entry** (6 bytes) + + 0000 [6]byte Level object prefix + +This entry does not contain any more information. diff --git a/modding/ss-specs/levelObjects/05_Hardware/levelHardwareEntry.md b/modding/ss-specs/levelObjects/05_Hardware/levelHardwareEntry.md new file mode 100644 index 0000000..409864e --- /dev/null +++ b/modding/ss-specs/levelObjects/05_Hardware/levelHardwareEntry.md @@ -0,0 +1,9 @@ +### Level Hardware Table (Class 5) + +```L15``` is a table describing hardware. + +**Level Hardware Entry** (7 bytes) + + 0000 [6]byte Level object prefix + 0006 byte Hardware version + diff --git a/modding/ss-specs/levelObjects/06_Software/levelSoftwareEntry.md b/modding/ss-specs/levelObjects/06_Software/levelSoftwareEntry.md new file mode 100644 index 0000000..069798c --- /dev/null +++ b/modding/ss-specs/levelObjects/06_Software/levelSoftwareEntry.md @@ -0,0 +1,92 @@ +## Level Software Table (Class 6) + +```L16``` is a table describing software - programs, logs, data... + +**Level Software Entry** (9 bytes) + + 0000 [6]byte Level object prefix + 0006 [3]byte Software data + + +### Cyberspace programs 6/0/x, 6/1/x, 6/2/x + +**Cyberspace program data** (3 bytes) + + 0000 byte Version + 0001 [2]byte Unused + + +> The three subclasses can be considered "aggressive programs", "defensive programs" and "boosts". Boosts ignore the version. +> Although several programs are defined, only a few are used. +> Different version numbers also change the color of the cube. + + +### Fun pack modules 6/3/0 + +**Fun pack module data** (3 bytes) + + 0000 byte Game mask + 0001 [2]byte Unused + +**Game mask** (1 byte) + + 0x01 Ping + 0x02 Eel Zapper + 0x04 Road + 0x08 Botbounce + 0x10 15 + 0x20 TriopToe + 0x40 Gameb (Not working, gives "not installed" message) + 0x80 Wing 0 + + +### Multimedia data 6/4/0, 6/4/1 + +**Multimedia software data** (3 bytes) + + 0000 byte Unused + 0001 byte Index + 0002 byte Type + +**Multimedia type** (1 byte) + + 0x00 E-Mail + 0x01 Log + 0x02 Data + +> In the game, e-mails are received through triggers, logs are only in real-world levels, and data only in cyberspace levels. The engine supports placing any of them in any level type. + + +#### Logs + +For logs, the ```Log resource offset``` is based on the resource ID ```0x09B8```. The high nibble is used as the level identifier - into +which category the log should be put. + +See [Electronic Messages](../../content/ElectronicMessages.md) for details on messages. + +> 6/4/0 are called "TEXT", behave like 6/4/1 "EMAIL", and are only used as logs. + + + +## Cyberspace "Scenery" Software (Class 7, in cyberspace) + +In cyberspace levels, ```L17``` is a table for additional software. It acts as an overflow for software if the first table is full. + +**Scenery Software Entry** (16 bytes) + + 0000 [6]byte Level object prefix + 0006 [10]byte Scenery software data + + +### Scenery software 7/3/2 ("GLOW BULB"), 7/2/0 ("SIGN") + +**Scenery software data** (10 bytes) + + 0000 int16 Parameter + 0002 int32 Subclass + 0006 int32 Type + + +For entries that specify a program, ```Parameter``` specifies the version. For fun packs, it is the game mask. + +> This type is only used for software with a single parameter; Found only for programs and fun packs. diff --git a/modding/ss-specs/levelObjects/07_BigStuff/levelBigStuffEntry.md b/modding/ss-specs/levelObjects/07_BigStuff/levelBigStuffEntry.md new file mode 100644 index 0000000..bf9cee1 --- /dev/null +++ b/modding/ss-specs/levelObjects/07_BigStuff/levelBigStuffEntry.md @@ -0,0 +1,220 @@ +## Level BigStuff Table (Class 7) + +```L17``` is a table describing big items in real-world levels. + +Cyberspace levels may re-use "big stuff" entries for further software entries. These entries need to be +interpreted differently. See [Software](../06_Software/levelSoftwareEntry.md). + + +**Level BigStuff Entry** (16 bytes) + + 0000 [6]byte Level object prefix + 0006 [10]byte BigStuff Info + +### Displays + +There are several big items that are capable of displaying (animated) content. They reuse the following common types: + +**Display Info** (10 bytes) + + 0000 int16 Frame count + 0002 int16 Loop type + 0004 int16 Alternation type + 0006 int16 Picture source + 0008 int16 Alternate picture source + +Animated screens are also controlled by the [Loop Configuration](../../archives/loopConfiguration.md). If not, they use their own loop type. + +**Loop Type** (1 byte) + + 0x00 Forward + 0x01 Forward/Backward + 0x02 Backward + 0x03 Forward/Backward + +> Other values have been found as well, without effect; The engine defaults to forward looping. + + +** Alternation Type** (1 byte) + + 0x00 Don't alternate + 0x03 Alternate randomly + +> Not all alternate source combinations work properly. Commonly it is used to alternate to a text. + + +**Picture Source** (2 bytes) + +This field specifies what shall be shown. It has the following cases: + + 0x0000 .. 0x00F5 Image frames, offset from resource 0x0141 + 0x00F6 Static fading into SHODAN's face + 0x00F7 Unknown (only found to display static) + 0x00F8 .. 0x00FF Surveillance screens 0 .. 7 + 0x0100 .. 0x017E Text message from resource 0x0877 + 0x017F Random number for CPU rooms, level 1-6 before destroying nodes. + 0x0180 .. 0x01FF Text message, scrolling vertically + +For surveillance screens, the sources of screens 0-7 are specified in the [Surveillance Sources Table](../../archives/surveillanceCameras.md). + + +The following big items support displays: +7/0/6 (TV), 7/0/7 (COMPUTER MONITOR), +7/2/6 (SCREEN, small), 7/2/8 (SCREEN, large), 7/2/9 (SCREEN, medium), +with variation: 7/5/5 (CONTROL PANEL), 7/5/6 (CONTROL PEDESTAL) (see below) + +### Electronics 7/0/x + +> Apart from some displays, nothing special + + +### Furniture 7/1/x + +#### Cabinets 7/1/2 + +**Cabinet Data** (10 bytes) + + 0000 [2]byte Unused + 0002 [2]int16 Object index + 0006 [4]byte Unused + +Cabinets can contain up to two objects. + + +#### Texturable furniture 7/1/5, 7/1/7, 7/1/8 (chairs and couches) + +**Texturable Furniture Data** (10 bytes) + + 0000 [6]byte Unused + 0006 int16 Texture index + 0008 [2]byte Unused + +If ```Texture index``` is not zero, it specifies the alternate texture to use for the "main" faces of the furniture. + + +### Surfaces 7/2/x + +These are big objects that are flat and should be placed at another surface. + +#### Words 7/2/3 + +**Words BigStuff Data** (10 bytes) + + 0000 int16 Text index (within resource 0x0868) + 0002 int16 Font and size (bits 0-3 are font, 4-7 are size) + 0004 int16 Colour (palette index, 0 defaults to red) + 0006 [4]byte Unused + + +#### Texture Maps 7/2/7 + +**Texture map big stuff** (10 bytes) + + 0000 [6]byte Unused + 0006 int16 Texture index + 0008 [2]byte Unused + +Texture maps allow to place one tile from the level texture list at any arbitrary position. + +### Lighting 7/3/x + +### Medical equipment 7/4/x + +#### Control Pedestals (buttons only) 7/4/0 + +These can trigger an object if used. + +**Button Control Pedestal Info** (10 bytes) + + 0000 [2]byte Unused + 0002 int16 Trigger object index + 0004 [6]byte Unused + + +#### Surgery Machines 7/4/3 + +**Surgery Machine Info** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Broken state; 0x00 = OK, 0xE7 = broken + 0003 [2]byte Unused + 0005 byte Broken message index; Broken default: 0x61 + 0006 [4]byte Unused + +With all bytes zero, the surgery machine works normally. + +When the ```Broken state``` field is set to a certain value (the game has ```0xE7``` for the one on level 2, though some other work, too), then the machine wouldn't work. When not working, the message identified with ```Broken message index``` is displayed in the HUD. (```0x61``` = "This machine is broken"). + + +#### Textureable Furniture 7/4/5 (chairs) + +These have the same layout as the general textureable furniture (see above). + + +### Science and security equipment 7/5/x + +#### Security Cameras 7/5/4 + +**Security Camera Info** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Panning switch; 0x00: stationary, 0x01: panning + 0003 [7]byte Unused + + +#### Control Pedestals (with display) 7/5/5, 7/5/6 + +Control pedestals are similar to displays regarding the source of the display. +Instead of having different loop options, they trigger an object if used. + +**Display Control Pedestal Info** (10 bytes) + + 0000 int16 Frame count + 0002 int16 Trigger object index + 0004 int16 Alternation type + 0006 int16 Picture source + 0008 int16 Alternate picture source + + +### Garden big stuff 7/6/x + +### Bridges 7/7/x + +#### Solid Bridges 7/7/0, 7/7/1 + +**Solid Bridge Info** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Size. Bits 0-3: X, bits 4-7: Y + 0003 byte Height. 0 is default, level height units otherwise. + 0004 byte Top/Bottom texture + 0005 byte Side texture + 0006 [4]byte Unused + +The size fields are defined as 0: 3D model default, 4: tile width. +The texture fields have bit 7 set to indicate texture from level texture list. If 0, the other 7 bits index +into ```citmat.res```. + +> If either textures are to be taken from the level list, the object description will be that of the textures. +> If a texture is to be taken from citmat.res, the texture index must be > 0, otherwise it will still be the first +> level texture, even if bit 7 is 0. + + +#### Force Bridges 7/7/7, 7/7/8, 7/7/9 + +**Force Bridge Info** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Size. Bits 0-3: X, bits 4-7: Y -- see above + 0003 byte Height. 0 is default, level height units otherwise. + 0004 [2]byte Unused + 0006 byte Colour + 0007 [3]byte Unused + +Object 7/7/8 is a placeholder for force bridges that have not been activated yet. These objects are invisible and are made to force bridges with the use of trigger action 24. + +Force bridges of type 7/7/9 differ from the typical ones (7/7/7) in having a default height of zero and no animation when switched from type 8. They furthermore need a size explicitly set. + +The engine supports several different values for ```Colour```. Used values are ```0x00``` for red and ```0xFE``` for green. + +> Not all 256 colour possibilities have been tested, many resulted in dark red tones or solid colours. Usable variants were found from ```0xF7``` and above. diff --git a/modding/ss-specs/levelObjects/08_SmallStuff/levelSmallStuffEntry.md b/modding/ss-specs/levelObjects/08_SmallStuff/levelSmallStuffEntry.md new file mode 100644 index 0000000..8626e97 --- /dev/null +++ b/modding/ss-specs/levelObjects/08_SmallStuff/levelSmallStuffEntry.md @@ -0,0 +1,186 @@ +## Level SmallStuff Table (Class 8) + +```L18``` is a table describing general small stuff. + +**Level SmallStuff Entry** (16 bytes) + + 0000 [6]byte Level object prefix + 0006 [10]byte SmallStuff data + +### Junk 8/0/x + +#### Papers 8/0/2 + +**Paper** (10 bytes) + + 0000 [2]byte Unused + 0002 int16 Paper ID + 0004 [6]byte Unused + +The ```Paper ID``` refers to a paper text entry and is the offset from resource ID ```0x003C```. +The referred text entries have multiple blocks and the engine simply concatenates all these strings to one text. + + +#### Briefcases 8/0/7 + +**Briefcase** (10 bytes) + + 0000 [2]byte Unused + 0002 [4]int16 Object index + + +### Broken Stuff 8/1/x + +Nothing special. + + +### Dead Bodies 8/2/x + +#### Corpses 8/2/0 - 8/2/7 + +**Corpse** (10 bytes) + + 0000 byte Treasure type + 0001 byte Unused + 0002 [4]int16 Object index + +The four ```Object index``` values determine the corpse's loot. More random loot can be added to empty slots based on the ```Treasure type```. + + +**Treasure Type Enumeration** (1 byte) + + 0xF5 No treasure + 0xF6 Humanoid + 0xF7 Drone + 0xF8 Assassin + 0xF9 Warrior cyborg + 0xFA Flier bot + 0xFB Security 1 bot + 0xFC Exec bot + 0xFD Cyborg enforcer + 0xFE Security 2 bot + 0xFF Elite cyborg + 0x00 Standard corpse + 0x01 Loot-oriented corpse + 0x02 Repairbot + 0x03 Serv-bot + + +> The treasure type values for corpses are the same as for [critter properties](../14_Critter/critterProperties.md), except offset by 11. + +#### Severed Heads 8/2/13, 8/2/14 + +**Severed Head** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Image index + 0003 [7]byte Unused + +If ```Image index``` is zero, the generic mutilated head is shown. Otherwise, it is the index for a picture of a person, using that of e-mails. + + +### Consumables 8/3/x + +Nothing special. + + +### Access Cards 8/4/x + +**Access Card** (10 bytes) + + 0000 int16 Unknown + 0002 sint32 Access mask + 0006 [4]byte Unused + +All access cards work the same way: When consumed, they add the given ```Access mask``` to the current priviledges and give a message whether and which new access was gained. + +**Access mask** (sint32) + + 0x00000001 "None" - This priviledge is none and will not be added. Should not be used. + 0x000000FE Various generic priviledges + 0x00FFFF00 Used for "group" priviledges; Typically used with 8/4/10 (Group access card) + 0x7F000000 Personal priviledges; 0x01: D'Arcy, 0x10: Diego + +8/4/11 items (Personal access cards) have a special handling if personal priviledges are set; When identifying the object, the name of the person is appended to the title. + +> 8/4/0 (Current access levels) works like any other access card, though it doesn't have a proper icon (only an 'X'). In the game it is used only on level 1 for a few cases, purpose unknown. + + +### Cyberspace Items 8/5/x + +#### Integrity Restorative 8/5/1 + +**Integrity Restorative** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Restoration amount + 0003 [7]byte Unused + +Per default (```Restoration amount``` = 0), the item restores about 3/4 of integrity. + +#### Security Defense Mine 8/5/2 + +**Security Defense Mine** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Damage amount + 0003 [7]byte Unused + +Per default (```Damage amount``` = 0), the item removes little amount (about one chevron). + + +#### Security ID Module 8/5/3 + +**Security ID Module** (10 bytes) + + 0000 int16 Unknown + 0002 sint32 Access mask + 0006 [4]byte Unused + +Works like access cards - see above. + +#### Cyber Info Nodes 8/5/6, 8/5/8 + +**Cyber Info Node** (10 bytes) + + 0000 [2]byte Unused + 0002 int16 Text index + 0004 [6]byte Unused + +For 8/5/6 (information nodes), the text index is for text resource ```0x0878```. +For 8/5/8 (data fragments), the text index is for text resource ```0x087A```. + + +#### Cyberspace Barricades 8/5/9 + +**Cyberspace Barricade** (10 bytes) + + 0000 [2]byte Unused + 0002 byte Size. Bits 0-3: X, bits 4-7: Y + 0003 byte Height. 0 is default, level height units otherwise. + 0004 [2]byte Unused + 0006 byte Colour + 0007 [3]byte Unused + +Layout and properties are the same as for force bridges (7/7/7). + + +#### Cyberspace Decoys 8/5/10 + +**Cyberspace Decoy** (10 bytes) + + 0000 [2]byte Unused + 0002 int16 Destroy object index + 0004 [6]byte Unused + +Normally, these cannot be destroyed in cyberspace. However, if placed in a map and made vulnerable to damage, decoys can be made to destroy another object when destroyed. + + +### Wear and Tear Stuff 8/6/x + +Nothing special. + + +### Quest Items 8/7/x + +Nothing special. diff --git a/modding/ss-specs/levelObjects/08_SmallStuff/smallStuffProperties.md b/modding/ss-specs/levelObjects/08_SmallStuff/smallStuffProperties.md new file mode 100644 index 0000000..8a1d540 --- /dev/null +++ b/modding/ss-specs/levelObjects/08_SmallStuff/smallStuffProperties.md @@ -0,0 +1,11 @@ +### Small Stuff Properties + +#### Specific 5 Properties + +These items (```8/5/x```) are cyberspace items. + +**Cyberspace Items Specific Properties** (`CyberSmallstuffProp` struct) (6 bytes) + + 0000 [6]byte Colour scheme + +```Colour scheme``` contains 6 palette indices from the game palette. The index into this list comes from the geometry properties, specifically from the [Set Colour and Shade](../../media/Geometry#set-colour-and-shade) command. diff --git a/modding/ss-specs/levelObjects/09_Fixture/levelFixtureEntry.md b/modding/ss-specs/levelObjects/09_Fixture/levelFixtureEntry.md new file mode 100644 index 0000000..9937a74 --- /dev/null +++ b/modding/ss-specs/levelObjects/09_Fixture/levelFixtureEntry.md @@ -0,0 +1,266 @@ +## Level Fixture Table (Class 9) + +```L19``` is a table describing fixtures and electrical stuff. + +**Level Fixture Entry** (30 bytes) + + 0000 [6]byte Level object prefix + 0008 [24]byte Fixture Specific Info + + +### Buttons 9/0/x + +Buttons come in various shapes and forms. All of them are triggers for actions and the entry data that of [actions](../Actions.md). + +**Button Fixture Specific Info** (24 bytes) + + 0000 [22]byte Action data + 0016 uint16 Access mask + + +The conditions for buttons are based on [game variables](../Conditions.md#game-variable-conditions). + +```Access mask``` is an additional condition for the button. If not zero, the lower five bits can specify one required access level the hacker must have. The remaining 7 bits don't work properly or resolve to the "None" level. + + +### Receptacles 9/1/x, 9/4/x + +Most receptacles work in conjunction with the quest items (8/7/x). With the exception of antenna relay panels, they contain regular actions as data. +Unless noted otherwise below, the conditions for receptacles are based on object types, with an optional trap message if the wrong item is used. + +**Receptacle Fixture Specific Info** (24 bytes) + + 0000 [22]byte Action data + 0016 [2]byte Unused + +**Receptacle Fixture Condition** (4 bytes) + + 0000 byte Object type + 0001 byte Object subclass + 0002 byte Object class + 0003 byte Trap message (optional) + + +#### Antenna Relay Panel 9/1/3 + +**Antenna Relay Panel Specific Info** (24 bytes) + + 0000 [6]byte Unused + 0006 int16 Trigger object index 1 + 000A int16 Trigger object index 2 + 000E int16 Destroy object index + +The two ```trigger object index``` values work only with trigger objects, they can't trigger arbitrary objects. + +> Behaviour of relays is hardcoded: After accepting the plastique, their type changes to 9/1/4 and a level timer is started. When the timer expires, the panel explodes, the type changes again to 9/1/5, the two objects are triggered and the object-to-destroy is removed. + + +#### Retinal ID Scanner 9/1/6 + +When using a head item on the retinal scanner, the condition field is compared against the head's ```Current Frame``` value (for object 8/12/13), or ```Current Frame + 11``` (for object 8/12/14). Note that this is different from the ```Image index``` value which determines how the head appears in the MFD. + +### Stations 9/2/x + +The conditions for stations are based on [game variables](../Conditions.md#game-variable-conditions). + +#### Cyberspace Terminals 9/2/0 + +**Cyberspace Terminal Specific Info** (24 bytes) + + 0000 byte State + 0001 byte Unused + 0002 [4]byte Condition + 0006 int32 Target X + 000A int32 Target Y + 000E int32 Target Z + 0012 int32 Target Level + 0016 [2]byte Unused + +The orientation of the player in the target cyberspace level can not be specified. The orientation will be taken over from the real world. + +**State** (1 byte enumeration) + + 0: Off + 1: Active (Normal state) + 2: Locked. Trying to activate it gives an electrical shock. + + +#### Energy Charge Stations 9/2/1 + +**Energy Charge Station Specific Info** (24 bytes) + + 0000 [2]byte Unused + 0002 [4]byte Condition + 0006 int32 Power amount + 000A int32 Recharge time in seconds + 000E int32 Object index + 0012 int32 Recharged timestamp + 0016 [2]byte Unused + +The object identified by ```Object index``` is triggered every time when energy is drawn from the station. + +The ```Recharged timestamp``` will be set to the game time when the station is usable again. There is no dedicated timer running. + + +### Input Panels 9/3/x + +The conditions for input panels are based on [game variables](../Conditions.md#game-variable-conditions). + +**Input Panel Info** (24 bytes) + + 0000 [2]byte Unknown + 0002 [4]byte Condition + 0006 [18]byte Input panel info + + +#### Access Panels (Puzzles) 9/3/0 to 9/3/3, 9/3/9, 9/3/10 + +Puzzles are either "wire" or "block" puzzles. The type of the puzzle is determined by a bit within the info structure. + +##### Wire Puzzles + +**Wire Puzzle Input Panel Info** (18 bytes) + + 0000 int32 Target object index (object to toggle on success) + 0004 byte Puzzle layout + 0005 uint8 Target power level out of 0xFF + 0006 uint8 Wire properties mask + 0007 int8 Puzzle type - must be 0x00 for wire puzzle + 0008 uint32 Target state of wires + 000C uint32 Current state of wires + 0010 [2]byte Unused + +**Puzzle Layout** (1 byte bitmask) + + 0x0F Wire count; 0 default to 4 wires + 0xF0 Connector count per size; 0 defaults to 6 connectors + +**Wire properties mask** (1 byte bitmask) + + 0x0F Color wires; All pink if not set + 0xF0 Solved state + +> For the properties mask, any of the respective four bits needs to be set to count for the whole nibble. +> ```Solved state``` is always ```0xF0```, whereas ```Color wires``` is only set as ```0x01```. + +Wire states are stored as three-bit pairs, ordered from LSB to MSB (right to left). The first pair describes the first +wire and the lower triple specifies the left connector. + +This allows for puzzles up to 5 wires and 8 connectors, although only up to 6 connectors are visible. + + +##### Block Puzzles + +**Block Puzzle Input Panel Info** (18 bytes) + + 0000 int32 Target object index (object to toggle on success) + 0004 int16 State store object index (refers to a null trigger) + 0006 byte Unused - 0x00 + 0007 int8 Puzzle type - must be 0x10 for block puzzle + 0008 int32 Puzzle layout + 000C [6]byte Unused + +**Puzzle Layout** (4 byte bitmask) + + 0x00000001 Puzzle solved flag ("Circuit Activated") + 0x0000000E Unknown + 0x00000070 Coordinate of source connector + 0x00000180 Source location + 0x00000E00 Unknown + 0x00007000 Coordinate of destination connector + 0x00018000 Destination location + 0x00700000 Width + 0x07000000 Height + 0x70000000 Side effect type + +The location values are 0: top, 1: bottom, 2: left, 3: right + +Side effects are: + + 0: Toggle all blocks horizontally/vertically from the changed one (across the board) + 1: Toggle all surrounding 8 blocks + 2: Toggle all blocks diagonally from the changed one (across the board) + 3: Toggle all blocks horizontally, vertically and diagonally across the board + 4: None + +The state of the puzzle is stored in the referred ```State store object```, a trigger with no action. + +The trigger info block is repurposed and at its offset ```0006``` there are 4 uint32 fields, concatenated to a 128 bit array. +Starting with the LSB from this bit array, 3 bits are repeatedly taken to fill the blocks from left to right, top to bottom. +This allows a maximum puzzle size of 42 blocks (6x7 or 7x6). + +The blocks are: + + 0: Nothing (empty) + 1: Inactive (x) + 2: Active (+) + 3: Active (+) - not used + 4: Full node + 5: Full node - not used + 6: Hollow node + 7: Hollow node - not used + +```Full nodes``` require only one incoming connection to forward power to the other three neighbours. +```Hollow nodes``` require two incoming connections to forward power to the other two neighbours. + +#### Elevator Panels 9/3/4, 9/3/5, 9/3/6 + +**Elevator Input Panel Info** (18 bytes) + + 0000 int16 Destination object index 2 + 0002 int16 Destination object index 1 + 0004 int16 Destination object index 4 + 0006 int16 Destination object index 3 + 0008 int16 Destination object index 6 + 000A int16 Destination object index 5 + 000C int16 Accessible bitmask (which floors are accessible) + 000E int16 Elevator shaft bitmask (which floors does the shaft connect) + 0010 [2]byte Unused + +If a floor bit is set in the ```Elevator shaft bitmask``` field but not in the ```Accessible bitmask``` field, using +that button gives the "Shaft damage" message. The MFD is capable of displaying up to 12 floors. + +The ```Destination object index``` values refer to the object index of the elevator panels in the other levels. Note the swapped order of the index values. Presumably the six values are loaded as three int32 values first, and then the index values are extracted. The Nth bit from the accessible bitmask, counted from LSB, references the Nth destination object index. + +For an elevator panel to work properly (and not crash the game), the following conditions must be met: +* The panel must be in a tile which has the elevator music set. (Music index ```0xF``` in tile flags.) +* The panels envolved in a level transition must reference each other. In other words, all panels of one connected elevator must have the same configuration. + +> Referencing an object from another level requires an editor to keep track of inter-level references. Or burden the user. +> The game uses only up to three active floors. + +> The elevator panel on level 1 has the first byte of the input panel structure set to ```0x01```. Purpose and effect unknown. + + +#### Number Pads 9/3/7, 9/3/8 + +**Number Pad Input Panel Info** (18 bytes) + + 0000 int16 3-digit combination 1 + 0002 int16 Trigger object index 1 + 0004 int16 3-digit combination 2 + 0006 int16 Trigger object index 2 + 0008 int16 3-digit combination 3 + 000A int16 Trigger object index 3 + 000C int16 Fail object index + 000E [4]byte Unknown + +The 3-digit combinations are stored in [Binary-Coded-Decimal](https://en.wikipedia.org/wiki/Binary-coded_decimal) right-bound, +so the combination ```451``` is stored as ```0x0451```. + +A combination must be something else than "000" to be active ("000" is always wrong). +Combinations must not be entered in sequence, any of the three combinations is valid at any time. + +If the ```Fail object index``` refers to a valid object, this one is triggered when a wrong combination was entered. + +> One number pad on level 0 has the first byte of the input panel structure set to ```0x06```. Purpose and effect unknown. + + +### Cyberspace Switches 9/5/x + +Cyberspace switches are triggers for actions and the entry data that of [actions](../Actions.md). + + 0000 [22]byte Action data + 0016 [2]byte Unused + +The conditions for cyberspace switches are based on [game variables](../Conditions.md#game-variable-conditions). diff --git a/modding/ss-specs/levelObjects/10_Door/levelDoorEntry.md b/modding/ss-specs/levelObjects/10_Door/levelDoorEntry.md new file mode 100644 index 0000000..da01d7d --- /dev/null +++ b/modding/ss-specs/levelObjects/10_Door/levelDoorEntry.md @@ -0,0 +1,29 @@ +## Level Door Table (Class 10) + +```L20``` is a table describing doors and barriers. + +**Level Door Entry** (14 bytes) + + 0000 [6]byte Level object prefix + 0006 [8]bytes Door Data + + +**Door Data** (8 bytes) + + 0000 int16 Lock variable index + 0002 byte Lock message + 0003 byte Force door colour index + 0004 byte Required access level + 0005 byte Auto-close time; in units of approximately 0.5 seconds - common default is 0x0C + 0006 int16 Second door object index + + +The ```Lock variable index``` determines whether a door is locked. If the referenced boolean variable is 1, then the door won't open when used. If the door is triggered by other means, the door is unlocked and the index is reset to zero. +Using the door while being locked, the message according to ```Lock message``` is shown. The message index is into the text resource ```0x0871```, with a block offset of ```7```. (```0x00``` gives the default "The door is locked" at index ```7```). + +The ```Required access level``` indicates which [access level](../content/AccessLevels.md) the hacker needs to activate +(open) the door. If zero, no special access is required. This values is ```0xFF``` for doors "locked by SHODAN level security". +Such doors have a separate trigger referring these doors. + +The ```Second door object index``` refers to a second door to trigger when activating this one. +Bulkhead doors for instance reference each other this way. diff --git a/modding/ss-specs/levelObjects/11_Animating/animatingProperties.md b/modding/ss-specs/levelObjects/11_Animating/animatingProperties.md new file mode 100644 index 0000000..3e05e74 --- /dev/null +++ b/modding/ss-specs/levelObjects/11_Animating/animatingProperties.md @@ -0,0 +1,27 @@ +### Animating Properties + +#### Generic Animating Properties + +**Generic Animating Propertes** (`AnimatingProp` struct) (2 bytes) + + 0000 byte Frame time + 0001 byte Flags + +The `frame time` specifies the time of a single frame of the animation. A value of `0xFF` means a time of approximately 0.9 seconds. All animations have this value set to `0x1E` (30). + + +**Animating Flags** (1 byte) + + 0x01 Emit light; The animation lights up the area with one tile radius for its duration. + + +> Light is only emitted by objects placed by the engine. This flag is only set for such temporary animations, such as explosions (`11/2/x`), but works for impacts as well (`11/1/x`). + + +#### Specific 2 Properties + +"Explosion" animations. + +**Specific 2 Properties** (1 byte) + + 0000 byte Frame explode diff --git a/modding/ss-specs/levelObjects/12_Trap/levelTrapEntry.md b/modding/ss-specs/levelObjects/12_Trap/levelTrapEntry.md new file mode 100644 index 0000000..b1cd7c5 --- /dev/null +++ b/modding/ss-specs/levelObjects/12_Trap/levelTrapEntry.md @@ -0,0 +1,136 @@ +## Level Trap Table (Class 12) + +```L22``` is a table describing trap items and general marker. + +**Level Trap Entry** (28 bytes) + + 0000 [6]byte Level object prefix + 0006 [22]byte Trap data + +### Infrastructure 12/0/x + +#### Critter AI Hints 12/0/7 + +**Critter AI Hint Data** (22 byte) + + 0000 [6]byte Unused; A few have the first byte 0x06 + 0006 int16 Next object index + 0008 [10]byte Unused + 0012 int16 Trigger object flag -- 0: off, 1: on + 0014 int16 Trigger object index + +AI hint marker are arranged to form a loop with their ```next object index``` fields. They describe a patrolling path for critters. +If the ```trigger object flag``` is set to ```0x01```, the object identified by ```trigger object index``` will be triggered approximately 1.5 seconds after a critter entered the tile this marker is in. + +> These patrolling paths appear to work only under special circumstances. They are rarely used in the main game and so far have been found to properly work only on level 9 for a mutated cyborg. +> The main reason for these hints not properly working is that roaming critters only walk when the hacker is within their vicinity - which often already makes them hostile. The cyborg on level 9 initially is docile, thus not concerned about a (passive) hacker. Its 4 state bytes are ```00 00 04 00``` and requires the second loot object to point to the first hint. +> Critters take these hints as such - they first walk into their direction, and continue to walk after passing them. Without a new hint, they will wander off in random directions. The cyborg on level 9 properly walks back and forth because its linear back-and-forth path has a hint for ever tile, and the two edges of the path are in coves, within which the cyborg bumps against the walls until properly turned around. + +#### Repulsors 12/0/10 + +**Repulsor Data** (22 byte) + + 0000 [10]byte Unused; A few have the first byte 0x06 without effect + 000A int32 Start height + 000E int32 End height + 0012 int32 Repulsion flags; 0x00000001: disabled (float down); 0x00000008: strong repulsor + +The ```start height``` and ```end height``` values have a unit of ```0x00010000``` for one tile height. An ```end height``` of zero means endless. +Both values are measured from the floor. Repulsion only happens if the hacker is within these limits. + + +#### Triggers 12/0/x (with exceptions) + +The type of the trigger object determines its cause. The trap data is then that of [actions](../Actions.md). + +Trigger Types: + + 0: Entry; Hacker enters tile (mid-air or per foot) + 1: Null; Must be set off externally. Also used as data storage. + 2: Floor; Hacker touches floor of this tile (per foot exclusively) + 3: Player Death; Used to resurrect Hacker + 4: Death Watch; When certain objects (or types of objects) are destroyed. Common for CPU nodes. + 5: Area Enter; Hacker enters (or leaves) a given radius + 6: Area Continuous; Hacker enters any tile inside (or outside) a given radius + 7: This is not a trigger! See AI hints above. + 8: Level Entry; Used for instance to initialize starting health + 9: Continuous; Unused + 10: This is not a trigger! See repulsors above. + 11: Ecology; Certain objects go extinct + 12: SHODAN; Triggered when level security changes + +##### Conditions + +Nearly all triggers have conditions based on [game variables](../Conditions.md#game-variable-conditions). + +The following exceptions exist: +* 4 (Death Watch Trigger): A union of [object type][obj-type-cond] and [object index conditions][obj-index-cond] +* 5, 6 (Area Trigger): A radius around the trigger. See below. +* 11 (Ecology Trigger): A combination of [object type][obj-type-cond] and a limit value. See below. + +[obj-type-cond]: ./Conditions.md#object-type-conditions +[obj-index-cond]: ./Conditions.md#object-index-conditions + +##### Area Triggers + +These triggers activate when the hacker enters or leaves a circular area around the trigger. The radius of this area is determined by the condition. + +**Area Trigger Condition** (4 bytes) + + 0000 sint32 Radius (in tiles) + +If the ```radius``` is between 1 and 4095, the trigger activates when the hacker is within a circular area of that size around the trigger's tile. A negative radius will activate the trigger when the hacker is outside of the circle instead (e.g. if the radius is -5, the trigger activates when the hacker is at least 5 tiles away from the trigger). + +Trigger type 5 ("Area Enter") activates once the hacker either enters (positive radius) or leaves (negative radius) the area. Type 6 ("Area Continuous") activates when the hacker enters any tile that is either inside (positive radius) or outside (negative radius) of the area. + +##### Ecology Triggers + +These triggers watch a certain type of objects as their condition, together with a limit value. Whenever the current count of these objects is changed to a number below the limit value, the action is executed. + +**Ecology Trigger Condition** (4 bytes) + + 0000 byte Type + 0001 byte Subclass + 0002 byte Class + 0003 byte Limit + +Note: The action is executed more than once. How often the action is executed is dependent on the delta. If the new object count equals to (limit-1), then the action is executed twice. If the new object count is less than that, the action is executed three times. + +> The layout for the C/S/T is the common one in an int32 value, with the high-byte representing the limit value. + +> The game watches only critters, typically to spawn new ones. The trigger also works with other classes. + + +### Trip Beams 12/1/x + +These are not used in the game. + + +### Map Marker 12/2/x + +Only two types are used in the game. + + +#### Map Notes 12/2/3 + +**Map Note Marker Data** (22 bytes) + + 0000 [18]byte Unused + 0012 int32 Entry offset + +The ```Entry offset``` points into ```L46``` ([map notes](mapInformation.md#map-notes)) for the text entry. When a map note is removed and the whole table is defragmented, all the entry offsets are changed accordingly. + + +#### Music Voodoo Marker 12/2/4 + +**Music Voodoo Marker Data** (22 byte) + + 0000 [6]byte Unused + 0006 byte Music flavour + 0007 [15]byte Unused + +The ```Music flavour``` is an index, with a range of ```0x00```..```0x04```, referencing patches of "music" resembling industrial noise. +They have a rectangular area of effect which covers 14x14 tiles, centered on the tile of the trap. +The music is only played if the current tile has a music index of zero. + +> This is used extensively on maintenance level, the 4 flight decks, as well as for the maintenance tunnels on the groves. diff --git a/modding/ss-specs/levelObjects/13_Container/levelContainerEntry.md b/modding/ss-specs/levelObjects/13_Container/levelContainerEntry.md new file mode 100644 index 0000000..9b51005 --- /dev/null +++ b/modding/ss-specs/levelObjects/13_Container/levelContainerEntry.md @@ -0,0 +1,25 @@ +## Level Container Table (Class 13) + +```L23``` is a table describing containers. + +**Level Container Entry** (21 bytes) + + 0000 [6]byte Level object prefix + 0006 [4]int16 Contained object indices + + for crates (13/0/x): + 000E byte Width + 000F byte Depth + 0010 byte Height + 0011 byte Top/Bottom texture number + 0012 byte Side texture number + +For the crate-specific values (dimensions and textures), if a value is 0, the default value from the 3D model is used. +The textures are taken from the resources in ```citmat.res```, based on ```0x0884```. + +> Texture numbers from ```0x80``` have been found to take the textures from the level texture list. +> It is unclear if this is intentional or a side-effect of how data is arranged in the engine. + +> Barrels (13/1/x) can not be inspected and may still contain items. Destroying a barrel drops its contents. +> There is one radioactive waste barrel (13/1/0) on level 4 which "contains" a hornet clip, which is lying right next to it. +> This is most likely the result of a mistake. diff --git a/modding/ss-specs/levelObjects/14_Critter/critterProperties.md b/modding/ss-specs/levelObjects/14_Critter/critterProperties.md new file mode 100644 index 0000000..d060eb8 --- /dev/null +++ b/modding/ss-specs/levelObjects/14_Critter/critterProperties.md @@ -0,0 +1,113 @@ +### Critter Properties + +#### Generic Critter Properties + +**Generic Critter Propertes** (`CritterProp` struct) (75 bytes) + + 0000 byte Intelligence; unused + 0001 [21]byte Primary attack info + 0016 [21]byte Secondary attack info + 002B uint8 Perception + 002C uint8 Defence + 002D sint8 Projectile source height offset + 002E int32 Flags + 0032 byte Mirrored views; unused + 0033 [8]uint8 Frame numbers; Amount for each posture + 003B uint8 Animation speed + 003C byte Attack sound index + 003D byte Near sound index + 003E byte Hurt sound index + 003F byte Death sound index + 0040 byte Notice sound index + 0041 int32 Corpse triple - 0x00CCSSTT; zero for "none" + 0045 byte View count; for multi-view postures + 0046 byte Secondary attack probability + 0047 byte Disrupt probability + 0048 byte Treasure type + 0049 byte Hit effect -- 0x00: meat, 0x01: plant, 0x02: robot, 0x03: cyborg, 0x04: generic + 004A byte Attack key frame + +`Perception` is a probability whether an AI detects hacker when visible during an AI interval. + +The `projectile source height offset` is a vertical offset specifying from where projectiles should be launched. +> This is set to some positive values for the Elite Guard and Mutated Cyborg, so that their projectils appear from their heads. + +The `animation speed` specifies the time of a single frame of an animation. A value of `0xFF` means a time of approximately 0.9 seconds. + +For all `sound indices`, a value of `0xFF` indicates "no sound". +> No enemy has a sound set for being hurt in the main game. + +`View count` determines how many frames are available per viewing direction. Cyberspace entities, as well as unused critters, have this set to `0x01`, all others have `0x08`. Changing these values messes up rendering of the critter, and/or all subsequent critters. The sequence of the corresponding images will have to be adjusted accordingly. + +The `secondary attack probability` determines how likely the critter will use its secondary attack when close to the hacker. `0xFF` means always, `0x00` will have the critter use the secondary attack only when farther away. + +The `disrupt probability` determines how likely the critter will be interrupted when being hit. `0xFF` means always, `0x00` will have the critter execute its attacks and movement without interruptions. + +`Treasure type` is an enumeration value (ranging from `0x00` to `0x0E`), pointing into a hardcoded pool of possible loot. See below for the possible constants. The concrete loot is randomized accordingly. + +The `attack key frame` value is an index into the attack animation, when the corresponding attack should take effect. + + +**Critter Attack Info** (`CritterAttack` struct) (21 bytes) + + 0000 uint8 Damage type + 0001 uint8 Special damage type + 0002 [2]byte Unused + 0004 int16 Damage modifier + 0006 uint8 Offence value + 0007 uint8 Penetration + 0008 uint8 Attack mass + 0009 int16 Attack velocity + 000B uint8 Accuracy + 000C uint8 Attack range + 000D int32 Reload time, with 0x100 being approximately 0.9 seconds + 0011 int32 Projectile triple -- 0x00CCSSTT; zero for "none" + +The `damage type` seems to be compatible with the damage type of [generic weapon info](../GenericWeaponInfo.md). Yet all damage types that affect hacker are handled equally; The two damage types `0x04` and `0x08` have no effect at all. +> The main game only uses damage types `0x01` and `0x02` (and some combinations). `0x04` is also set for Diego as well as SHODAN, though this has no further effect. + +The `offence value` is compared to the defence value of the target object. See [common properties](../../fileFormat/PropertyFiles.md#common-table) for details. + +An `accuracy` of `0x00` will still have the critter hit, though not that often and with less damage. + +The critter will want to get within the `attack range` to perform its attack. + + +**Critter Properties Flags** (4 bytes) + + 0x00000001 Flying + +> There are further constants defined, though not in use by the engine. +> The properties file has some critters with flag `0x00000004` set, which indicated "small" critters. + + +**Treasure Type Enumeration** (1 byte) + + 0x00 No treasure + 0x01 Humanoid + 0x02 Drone + 0x03 Assassin + 0x04 Warrior cyborg + 0x05 Flier bot + 0x06 Security 1 bot + 0x07 Exec bot + 0x08 Cyborg enforcer + 0x09 Security 2 bot + 0x0A Elite cyborg + 0x0B Standard corpse + 0x0C Loot-oriented corpse + 0x0D Repairbot + 0x0E Serv-bot + + +#### Specific 3 Properties + +These critters (`14/3/x`) are for cyberspace. + +**Cyberspace Critters Specific Properties** (`CyberCritterProp` struct) (6 bytes) + + 0000 [3]byte Colour scheme + 0000 [3]byte Alternative colour scheme + +The `colour scheme`s contain 3 palette indices from the game palette. The 'alternative' colours are used for critters attacking or being hostile. +The index into this list comes from the geometry properties, specifically from the [Set Colour and Shade](../../media/Geometry#set-colour-and-shade) command. diff --git a/modding/ss-specs/levelObjects/14_Critter/levelCritterEntry.md b/modding/ss-specs/levelObjects/14_Critter/levelCritterEntry.md new file mode 100644 index 0000000..29b3b24 --- /dev/null +++ b/modding/ss-specs/levelObjects/14_Critter/levelCritterEntry.md @@ -0,0 +1,89 @@ +## Level Critter Table (Class 14) + +```L24``` is a table describing critters. + +**Level Critter Entry** (46 bytes) + + 0000 [6]byte Level object prefix + 0006 [40]byte Level critter data + + +**Level Critter Data** (40 bytes) + + 0000 int32 Pending Z-rotation. Values up to 0x608000 seen. + 0004 int32 Fixed forward velocity. Rare values of 0x0D000 and 0x090000 seen. + 0008 [2]byte Unknown, Unused + 000A int16 Hastiness. Values of 0x0050 and 0x0030 seen. + 000C int16 State timeout + 000E int16 Unknown - set to 0x0002 for a few critters. + 0010 int16 Unknown - set to arbitrary values. + 0012 [2]byte Unused + 0014 byte Roaming critter state + 0015 byte Primary critter state + 0016 byte Secondary critter state + 0017 byte Tertiary critter state + 0018 [6]byte AI coordinates A + 001E byte Always 0xFF + 001F byte AI coordinates B + 0020 [2]int16 Loot object indices + 0024 [2]byte Unknown, Unused + 0026 [2]byte Unknown, set only for a few roaming critters. + + +```Pending Z-rotation``` is a rotation value that the critter wants to perform next. Only when this value is zero, it continues with its regular state. + +```Fixed forward veloctiy``` is used for a few critters to give them a fixed forward speed. This speed can be different than their normal speed, and even make them slower than regular. + +```Hastiness``` makes a few critter appear as if they were under the effect of a speedup-stimulant. Their actions, including their animations, are quicker. + +```State timeout``` is an indicator on how long the critter will stay in this state. Observed only in "cautious" state. +This time is reduced as long as the hacker is in the vicinity. When the time reaches zero, the critter becomes hostile. +The time value is typically quite low. A few high values like 0x0062 have been observed. + +```AI coordinates A``` and ```B``` appear to be some sort of coordinates. Changing them has no visible effect, so they may be scratch values for the AI, determining where to go next. They are used only for roaming critters. + +```Always 0xFF``` must always be set to ```0xFF```, otherwise the critter is immediately hostile, regardless of where it is on the map. All critters in the main game have it set to ```0xFF```. + +### Critter states + +The state a critter is in is goverend by four bytes, which have meanings on their own, as well as combined effects. For simple cases, only the ```Primary critter state``` is used. This value is also the one modified by the "Set critter state" action. + +A roaming critter has the ```Roaming critter state``` value set, and also needs other states to be set. Working combinations found so far are: +* ```03 01 01 00``` +* ```02 01 01 00``` (not found in archive.dat) +A critter only roams on its own if the hacker is within a radius of about 6 tiles. + +A patrolling critter follows a path laid out by ```Critter AI Hint``` marker (See [traps](../12_Trap/levelTrapEntry.md#critter-ai-hints-1207)). The first marker of such a path must be referenced in the second ```loot object index```. +> The only working example of such a critter is initially docile, and has a state combination of ```00 00 04 00```. + +The ```Primary critter state``` holds the state the critter is currently behaving in. This state is also reported by the targeting tool. + +**Primary Critter State** (1 byte) + + 0x00 docile + 0x01 cautious + 0x02 hostile + 0x03 cautious (?) + 0x04 attacking + 0x05 sleeping + 0x06 tranquilized + 0x07 confused + +> Various combinations of the state values can be found in the main game. While they have different effects, their pattern is not clear. + +**Critter Mood Enumeration** + + 0 Friendly + 1 Neutral + 2 Hostile + 3 Isolation + 4 Attacking + +**Critter Orders Enumeration** + + 0 Guard + 1 Roam + 2 Sleep + 3 Patrol between two points + 4 Highway - Follow path + 5 No move (guarding without moving) diff --git a/modding/ss-specs/levelObjects/Actions.md b/modding/ss-specs/levelObjects/Actions.md new file mode 100644 index 0000000..e59a76b --- /dev/null +++ b/modding/ss-specs/levelObjects/Actions.md @@ -0,0 +1,786 @@ +## Actions + +Level traps and various fixtures (buttons and the like) make use of actions that perform some state change and/or give some indication to the player. They are essentially what make a level (and the game in total) come to life. + +Actions can be triggered by various sources. They may have conditions that first have to be met before the action is actually performed. The type of the action determines how to interpret the details (parameters) of the action. + + +### Quest Value Key + +Some actions make use of quest value keys, which are used to either resolve a raw value, or a value from the game variables ("quest values"). + +* If bit ```0x1000``` is set in the key, the current integer game variable value of index ```key & 0x0FFF``` is referenced. +* If bit ```0x2000``` is set in the key, the current boolean game variable value of index ```key & 0x0FFF``` is referenced. +* For resolving values, if the key is less than 0x1000, then the resulting value is the value key itself. + + +### Action Data + +**Action Data** (22 bytes) + + 0000 byte Action type + 0001 byte Use quota + 0002 [4]byte Condition + 0006 [16]byte Action details + +The ```Use quota``` indicates how often more this action can be executed. ```0``` means endless. +The value is only decremented if it is not zero and the condition was met. +If the value is decremented to zero after the action was executed, the object where the action is in is deleted. + +Internally the ```action details``` are processed as four sint32 values. This documentation dissects them further where appropriate. + +### Action Type 1: Transport hacker + +**Transport Hacker Action Details** (16 byte) + + 0000 int32 Target X + 0004 int32 Target Y + 0008 int32 Target Z + 000C int32 Target Level + +For all three target axis values, if they are ```0x4000``` or greater, they are ignored and the respective axis is not changed in the transport. +The ```X``` and ```Y``` axes specify tile numbers, the ```Z``` axis is interpreted in level units. + +> In the main game, this axis-preservation is used only on level 2 for the Z axis on the experimental transporter, +> which incidentally has both platforms at the same height. + +If to be considered, the target values are then used as quest value keys to resolve the actual location for the respective axis. + +> The main game does not make use of this quest value lookup feature. All encounterd values are direct values. + +The ```target level``` specifies a cross-level transfer to the identified level if the value is smaller than ```0x1000```. +If it is ```0x1000``` or greater, transportation is only done within the same level, regardless of the value. + +> Cross-level transport is used only for a same-level transfer on level 4 (restoration chamber), and twice for two groves, to +> transport the hacker to the restoration chamber on level 6. Other instances are set to ```0x2222```, which are ignored. + + +### Action Type 2: Change Health + +This action is also called "damage", probably based on the first parameter. + +**Change Health Action Details** (16 byte) + + 0000 int32 Damage delta + 0004 int16 Health delta + 0006 byte Health change flag; 0: remove delta, 1: add delta + 0007 byte Damage type -- used if removing health + 0008 int16 Power delta + 000A int16 Power change flag; 0: remove delta, 1: add delta + 000C int16 Fatigue delta + 000E int16 Fatigue change flag; 0: add delta, 1: remove delta + +All four ```delta``` values are used as quest value keys to resolve the actual delta value. + +> The ```damage delta``` is not used in the main game. +> Although intended to cause explosion damage, due to mixed up parameters in the code, a fixed amount of damage is done, +> with the delta value being interpreted as damage type. + +> Reducing health to/below 0 causes death. Reducing power below 0 causes an underflow and hacker is fully powered again. + + +### Action Type 3: Clone/Move Object + +> This action is internally called "Create Object". Name not kept to describe actual functionality better. + +**Clone/Move Action Details** (16 byte) + + 0000 int16 Source object index -- quest value key + 0002 int16 Move flag + 0004 int32 Target X tile + 0008 int32 Target Y tile + 000C int32 Target Z + +The ```move flag``` has a dual purpose. First, it is used to determine whether the object shall be moved or cloned. +If it is zero, then the object is cloned; If it has bit ```0x1000``` set, then the object is cloned as well; All other values cause the source object to be moved. +If the object is cloned, then the ```move flag``` parameter will be used as quest value key to store the object index of the newly created object. + +> The game archive has various values for the ```move flag```. + +For all three target axis values, if they are ```0x4000``` or greater, they are ignored and the respective axis is not changed. +The ```X``` and ```Y``` axes specify tile numbers, the ```Z``` axis is interpreted in level units. + +> In the main game, this axis-preservation is used with various values, including ```0x4400```. + +If to be considered, the target values are then used as quest value keys to resolve the actual location for the respective axis. + +The orientation and in-tile position of the source object will be kept. + + +### Action Type 4: Set Game Variable + +> This action is internally called "Quest Bit". Name not kept to describe actual functionality better. + +**Set Game Variable Action Details** (16 byte) + + 0000 int32 Quest value key + 0004 int16 Value + 0006 int16 Operation + 0008 int32 Message 1 (within resource 0x0867) + 000C int32 Message 2 (within resource 0x0867) + +Depending on the ```quest value key```, different sets of variables are modified. +The variables are stored in the [Game State Structure](../../archives/gameState.md). +If no bit in the nibble of ```0xF000``` is set, the quest value key defaults to boolean variables. + +> For the most part in the main game, the boolean variable selector ```0x2000``` is not set and thus relies on this default handling. +> There are a few cases where this boolean variable selector is set. + +For the int16 variables, ```value``` must be in the range ```0..0x0FFF```. ```Operation``` can be one of the following: + + 0: Set value + 1: Add value + 2: Subtract value + 3: Multiply with value + 4: Divide by value (Resulting fractions are cut, divide by 0 is ignored) + 5: Modulo by value + +For the boolean variables, ```operation``` is ignored and always ```0```. +The values are either ```0``` or ```1``` to set that value, or ```2``` to toggle the current value. + +If the resulting value is not zero, the (optional) ```message 1``` is played/shown. If the resulting value is zero, the (optional) ```message 2``` is played/shown. Both message parameters are quest value keys. + +> Operations other than Set or Add are not used in the game for the int16 values. + + +### Action Type 5: Cutscene + +**Cutscene Action Details** (16 byte) + + 0000 int32 Cutscene index; 0: Death, 1: Intro, 2: Ending + 0004 int32 End game flag + 0008 [8]byte Unused + +```End game flag``` must be ```1```. Setting it to ```0``` leaves the game in an unplayable state after the cutscene. + +What happens after the cutscene is dependent on the cutscene itself: The death and intro cutscenes return to the main menu, +the ending cutscene ends the game (showing score and credits). + +> In the regular game, this action is used only once when defeating SHODAN. + + +### Action Type 6: Trigger other objects + +**Trigger Objects Action Details** (16 byte) + + 0000 int16 Object 1 index + 0002 int16 Object 1 delay + ... + 000C int16 Object 4 index + 000E int16 Object 4 delay + +The delays are all relative to the execution start of the action. Their unit is 0.1 of a second. + +All four object indices are quest value keys. They are resolved when this action is executed, not after the delay. + + +### Action Type 7: Change lighting + +**Change Lighting Action Details** (16 byte) + + 0000 int16 Light type extent - quest value key + 0002 int16 Reference object index - quest value key + 0004 int16 Transition type. 0x0000: immediate, 0x0001: fade, 0x0100: flicker + 0006 int16 Transition state + 0008 byte Light type + 0009 byte Unused + 000A int16 Light surface. 0x0000: floor, 0x0001: ceiling, 0x0002: floor and ceiling + 000C [4]byte Light type parameter + +These actions modify the light deltas of the affected tiles. Such actions can not change the base darkness +values of a tile. If a tile is fully bright by default, nothing can change that. +The configured light parameters are absolute values. + +The ```transition state``` is a bitmask storing the state for a change. + +**Transition state** (2 bytes) + + 0x0FFF Step counter + 0x1000 0: light on, 1: light off + +**Light type** (1 byte) + + 0x00 Rectangular + 0x01 Linear gradient (East-West smooth) + 0x02 Linear gradient (North-South smooth) + 0x03 Radial gradient + +**Gradient light type parameter** (4 bytes) + + 0000 byte Off light begin intensity + 0001 byte Off light end intensity + 0002 byte On light begin intensity + 0003 byte On light end intensity + +#### Rectangular light (type 0x00) + +For this light type, the ```light type extent``` specifies a second object index. All tiles within this rectangle are affected. + +**Light type parameter** (4 bytes) + + 0000 byte Off light value 0x00..0x0F + 0001 byte On light value 0x00..0x0F + 0002 [2]byte Unused + +#### Linear gradient light (types 0x01, 0x02) + +For this light type, the ```light type extent``` specifies a second object index to define the affected area. + +For these types, the ```gradient light type parameter``` intensity values have a range of ```0x00```..```0x0F```. + +> These light types are not used in the regular game. + + +#### Radial gradient light (type 0x03) + +For this light type, the ```light type extent``` specifies a radius, in tiles, of affected tiles. The begin of the gradient is always the +tile of the triggering object (```reference object index``` is ignored). + +The intensity values of the ```gradient light type parameter``` have a range of ```0x00```..```0x7F```. +A value of around ```0x14``` lights a tile as if it were fully lit. +Values above will spill over to the surrounding tiles. + + +### Action Type 8: Effect + +**Effect Action Details** (16 byte) + + 0000 int16 Sound index, based on 0x00C9; 0: Play nothing + 0002 int16 Sound play count; 0: endless + 0004 int16 Visual effect + 0006 [2]byte Unused + 0008 int16 Additional visual effect + 000A [2]byte Unused + 000C int16 Additional visual effect time; 0: endless + 000E [2]byte Unused + +All parameters are used as quest value keys. + +Visual Effects: + + 0: None + 1: Power on: Dim level lights and fade back to normal; Plays generator sound as well + 2: Quake: Shake cam and play rumble sound + 3: Escape pod: Shows launch sequence, cam shake and abort; Plays SHODAN message afterwards + 4: Red static (fullscreen) + 5: Red static (interference) + +Additional Visual Effects: + + 0: None + 1: White flash + 2: Pink flash + 3: Gray static (fullscreen) + 4: Vertical panning (including HUD) + + +### Action Type 9: Change tile heights + + 0000 int32 Tile X + 0004 int32 Tile Y + 0008 int16 Target floor height; 0..31, measured from floor absolute zero + 000A int16 Target ceiling height; 1..31, measured from floor absolute zero + 000C [2]byte Unused + 000E int16 Silent flag; 0: play sound, 1: no sound + +Tile coordinates, as well as the heights, are used as quest value keys. + +For the height fields a value equal to, or greater than ```0x0100``` indicates "don't move". + +> The ceiling height has also been encountered to be ```0x0020```, also not moving. + +The ```silent flag``` only works for floor changes. If the ceiling changes height, it makes sound regardless of this parameter. + + +### Action Type 10: Change Terrain + + 0000 int32 Tile X + 0004 int32 Tile Y + 0008 int32 Tile type + 000C int32 Terrain parameter (slope height) + +All four parameters are used as quest value keys. + +If ```tile type``` or ```terrain parameter``` has the bit ```0x4000``` set, the respective property will not be changed. + +> This action is not used in the main game. + + +### Action Type 11: Scheduled Trap + +**Scheduled Trap Action Details** (16 byte) + + 0000 int32 Object index + 0004 int32 Time interval + 0008 int32 Activation value + 000C int16 Variance + 000E [2]byte Unused + +Parameters ```object index```, ```time interval```, and ```variance``` (if set != 0) are used as quest value keys. + +The ```time interval``` specifies in 0.1 seconds units within which time span the identified object shall be triggered regularly. +The ```variance``` adds some randomness to the interval. Usually low numbers are encountered in the main game. + +> The unit of the variance is that of the engine-internal ticks. + +The ```activation value``` has three different uses: +* greater than or equal to ```0xFFFF```: endless scheduling. +* bit ```0x1000``` set AND boolean game variable identified by this value key is true: schedule trigger +* value between ```0x0000``` (exclusive) and ```0x1000``` (exclusive): remaining schedule amount, down to zero + +> The game has the ```Activation value``` typically set to ```0xFFFF```, ```0x10000``` or ```0x11111``` in the main game. + + +### Action Type 12: Cycle Objects + +> Internally, this action is called "Alternating splitter" + +**Cycle Objects Action Details** (16 byte) + + 0000 [3]int32 Object indices + 000C int32 Next object + +All three ```object indices``` are interpreted as quest value keys. + +This action triggers objects from the list of ```object indices```. The ```next object``` field indicates which entry to trigger next. +Afterwards, the ```next object``` field is incremented. It is reset to ```0``` if it is either greater than ```2```, or it is ```2``` and the third object index is ```0```. + + +### Action Type 13: Destroy Object + +**Destroy Object Action Details** (16 byte) + + 0000 int16 Object 1 index + 0002 int16 Unused + 0004 int16 Object 2 index + 0006 int16 Unused + 0008 int16 Object 3 index + 000A int16 Unused + 000C int32 Message index (within resource 0x0867); 0: no message + +All three ```object indices``` are interpreted as quest value keys. + + +### Action Type 14: Unused + +> This action is not used and does nothing. + + +### Action Type 15: Receive EMail + +**Receive EMail Action Details** (16 byte) + + 0000 int16 EMail index, based on 0x0989 + 0002 [2]byte Unused + 0004 int16 Delay + 0008 [10]byte Unused + +The ```Delay``` is roughly counted in seconds. Tests showed their actual unit seems to be somewhere at 0.9 seconds. + + +### Action Type 16: Expose + +**Change Effect Action Details** (16 byte) + + 0000 int16 Delta value + 0002 int16 Effect change flag; 0: add delta, 1: remove delta + 0004 int32 Effect type + 0008 [8]byte Unused + +This action exposes the hacker to some typed hazard or treatment. + +Effect types are: + + 4: Radiation poisoning + 8: Bio contamination + +> The main game uses this only once to remove the radiation in level R - treatment area. + + +### Action Type 17: Set Object Parameter + +**Set Object Parameter Action Details** (16 byte) + + 0000 int16 Object index 0 + 0002 int16 Object index 2 + 0004 int32 Value 1 + 0008 int32 Value 2 + 000C int32 Value 3 + +Both object indices are treated as quest value keys. + +This action sets parameters of target objects. Which value is applied to which parameter is dependent on the target. + +The following table lists the mappings. + +| Target Class | Value 1 | Value 2 | Value 3 | +|:-------------:|-----------------|----------------------------------|-----------------| +| Big Stuff | Cosmetic value | Data1 | Data2 | +| Door | Locked | ```0xFF00```: Message index, ```0x00FF```: Cosmetic value | ```0xFF00```: Access level, ```0x00FF```: Auto-close time | +| Fixture, Trap | Parameter Number (1..4) | Parameter Value | Unused | + +For ```big stuff``` and ```door``` classes, if a value is ```-1``` it doesn't modify the respective target parameter. + +> Which parameters are specifially affected for fixtures and traps depends on their respective interpretation of the 16 bytes. + + +### Action Type 18: Set Screen Picture + +> Internally, this action is called "animate". + +**Set Screen Picture Action Details** (16 byte) + + 0000 int16 Screen 1 object index + 0002 int16 Screen 2 object index + 0004 int32 Single sequence source + 0008 int32 Loop sequence source + 000C int32 Unused + +This action sets the picture source for the given screen object(s). + +If provided, the ```Single sequence``` source is played first. If provided, the ```Loop sequence``` is played +next and looped. If no loop is provided, the last frame of the single sequence will be kept. + + +### Action Type 19: Hack + +This action is a more generic one with several different interpretations, depending on the first field. + +**Hack Action Details** (16 byte) + + 0000 int32 Hack type + 0004 [12]byte Hack parameter + + +#### Hack Action Type 1: Toggle repulsor + +**Toggle Repulsor Details** (12 byte) + + 0000 int32 Repulsor object index + 0004 byte "Off" texture index (into level texture list) + 0005 byte "On" texture index (into level texture list) + 0006 [2]byte Unused + 0008 byte Toggle type + 0009 [3]byte Unused + +**Toggle type** (1 byte) + + 0x00 Toggle On/Off + 0x01 Toggle On, stay on + 0x02 Toggle Off, stay off + + +#### Hack Action Type 2: Show Game Code Digit + +**Show Game Code Digit Details** (12 byte) + + 0000 int32 Screen object index + 0004 int32 Digit number 1..6 (= level number) + 0008 [4]byte Unused + + +#### Hack Action Type 3: Set Fixture Parameter from Variable + +**Set Fixture Parameter From Variable Details** (12 byte) + + 0000 int32 Fixture object index + 0004 int32 Parameter number (1..4) + 0008 int32 Game integer variable index + +> This hack is used only twice, exclusively to set the codes for the reactor in the base game. +> Similar to the "Set Object Parameter" action, the interpretation of the modified parameter is depending on the action in the target fixture. + + +#### Hack Action Type 4: Set Frame State + +> Although this hack is called "fixture frame", it doesn't check for the type of the target object and allows to modify any object's frame state. + +**Set Frame State Details** (12 byte) + + 0000 int32 Button object index + 0004 int32 New frame state; 0: off, 1: on + 0008 int32 MFD update flag; 0: ignored, != 0: update MFD + + +#### Hack Action Type 5: Door Control + +**Door Control State Details** (12 byte) + + 0000 int32 Door object index + 0004 int32 Control value + 0008 int32 Unused + +Control values: + + 1: open door + 2: close door + 3: toggle door + 4: disable auto-close + + +#### Hack Action Type 6: Return to main menu + +This hack has no parameters (all 12 bytes 0x00) and directly returns to the main menu. + + +#### Hack Action Type 7: Rotate Object + +**Rotate Object State Details** (12 byte) + + 0000 int32 Object index + 0004 byte Amount + 0005 byte Type -- 0x00: endless, != 0x00: back/forth + 0006 byte Direction -- 0x00: forward, 0x01 when running backwards + 0007 byte Axis -- 0x00: Z (yaw), 0x01: X (pitch), 0x02: Y (roll) + 0008 byte Forward limit + 0009 byte Backward limit + 000A [2]byte Unused + +This hack rotates an object along one axis. This rotation can be endless in one direction, or a back and forth rotation, +depending on the ```Type``` field. + +If the rotation type is back and forth, then the object is rotated between the two limits. Should the object start outside of these +limits, then rotation continues normally until it is within the limits. + +If the rotation type is endless, whenever one limit is reached, the object immediately jumps to an orientation as per the other limit. + + +#### Hack Action Type 8: Remove Critters + +> This hack is called "armageddon" internally, most likely in a reference to a similar action in the game Lemmings. + +**Remove Critters State Details** (12 byte) + + 0000 int32 Object type - 0x00CCSSTT + 0004 int32 Radius + 0008 int32 Unused + +This hack removes critters of the specified object type from the level. +The ```radius``` specifies how many shall be removed around the tile of the action's object, in tiles. + +> Avoid using high amounts. The engine can remove only a certain amount of objects in one tick (100) and has no guard if more are removed. +> A memory overrun occurs in this case. + + +#### Hack Action Type 9: SHODAN pixelation + +This hack has no parameters (all 12 bytes 0x00) and fills the screen with an image. The used image is hardcoded. +The game returns to the main menu after the screen is filled. +It should be used only in cyberspace; Using it in real world messes up tile textures. + + +#### Hack Action Type 10: Set Condition + +**Set Condition Details** (12 byte) + + 0000 int32 Object index + 0004 [4]byte Condition + 0008 [4]byte Unused + +Sets the condition values of specified object (fixture or trap). + + +#### Hack Action Type 11: Show System Analyzer + + 0000 int32 Page index (0..2) + 0004 [8]byte Unused + + +#### Hack Action Type 12: Make Area Radioactive + +> Internally this hack is called "spew". Yet it only supports spillage of radiation. + +**Make Area Radioactive Details** (12 byte) + + 0000 int32 Reference object index + 0004 int16 Watched object index + 0006 int16 Watched object trigger state + 0008 int32 Spew type. Only value of 0 supported (= radiation). + +The rectangular area defined by ```reference object``` and the object of the action emits radiation while ```watched object``` is in given state (1 = active/open). + +> This is used only once, on level 2 for the small room in Beta quadrant. The door is both the reference as well as the watched object. + + +#### Hack Action Type 13: Oriented Trigger Object + +> This hack is exclusively used on level 8 to trigger three different taunts from Diego. +> Hence it is also called "Diego Taunt" in the source. + +**Oriented Trigger Object Details** (12 byte) + + 0000 uint16 Horizontal direction + 0002 [2]byte Unused + 0004 int32 Object index + 0008 [4]byte Unused + +This hack triggers the given object only if the player looks to the given ```Horizontal direction``` (+/- 45°). +Value ```0x0000``` specifies north, going clockwise with increasing numbers, meaning: + + 0x0000 North (matches between NW and NE) + 0x4000 East (matches between NE and SE) + 0x8000 South (matches between SE and SW) + 0xC000 West (matches between SW and NW) + + +#### Hack Action Type 14: Close Data MFD + +**Close Data MFD Details** (12 byte) + + 0000 int32 Displayed object index + 0004 [8]byte Unused + +If the specified object is currently displayed in the "Data" MFD, close it and return to previous display. + +> This hack is used only once to close the keypad panel from the reactor if an invalid combination is entered. + + +#### Hack Action Type 15: Earth destruction by laser + +This hack has no parameters (all 12 bytes 0x00) and lets the player receive the message about the fired laser. +The game continues after the message has played. Images and text are hardcoded. + + +#### Hack Action Type 16: Change Critter Type Global + +**Change Critter Type Global Action Details** (16 byte) + + 0000 int32 Critter type - 0x00CCSSTT + 0004 byte New type + 0005 [7]byte Unused + +This hack morphs all critters of given object type in the level and changes their type to the given one. +Class and subclass stay the same. Stats, such as healthpoints, are maintained as well. + +If ```new type``` specifies a value greater than ```0x0F```, the object will be destroyed. + + +### Action Type 20: Set Tile Textures + +> This action is not used in the main game. + +**Set Tile Textures Action Details** (16 byte) + + 0000 int32 Area + 0004 int32 Floor Operation + 0008 int32 Wall Operation + 000C int32 Ceiling Operation + +The ```area``` defines a rectangular area of affected tiles. +If ```area``` is less than ```0x10000```, then only one tile is affected, identified by ```0xXXYY```. +Otherwise, ```area``` is interpreted as two object indices (interpreted as quest value keys), that define the area by their respective location. + +The three ```operation``` fields identify, per texture, what to do. + +**Texture Operation** (4 byte) + + 0000 int16 Source filter + 0002 int16 Destination texture + +If ```source filter``` is either equal to or greater than ```0x1000```, or the tile uses the texture identified by this filter, then set the texture according to ```destination texture``` if this value is less than ```0x1000```. + +Said differently, try to set ```destination texture``` only if the value is less than ```0x1000```, and consider only tiles if set as a flood-fill, or if the source matches. + + +### Action Type 21: Set Critter State + +**Set Critter State Action Details** (16 byte) + + 0000 int16 Filter parameter + 0002 int16 Filter type + 0004 int32 Area + 0008 int32 New critter mood -- ignored if value >= 0x1000 + 000C int32 New critter orders -- ignored if value >= 0x1000 + +Sets the state of critters. +See [Critters](14_Critter/levelCritterEntry.md) for the enumeration values of critter states. + +The ```filter type``` parameter defines via bits which critters should be considered and how the ```filter parameter``` is interpreted. + +**Filter Type Bitfield** (2 byte) + + 0x0001 Consider loner critters only. If not set, only consider pack critters. + 0x0002 Ignore loner/pack property of critter. + 0x0004 If set, ```filter parameter``` identifies one specific critter to affect (regardless of remaining bits). Use area otherwise. + 0x0008 Affect every critter in the area, regardless of subtype/type. (Note: only works for small areas) + +If neither bits ```0x0004```, nor ```0x0008``` are set, then ```filter parameter``` specifies the subclass and type of affected critters (```0xSSTT```). + +The ```area``` parameter defines the affected tiles in the following ways: +* All zero: affect entire level +* Lower 16 bits zero: upper 16 bits define half of the side-length of a square, with the tile of the action object at its center. +* Two 16 bit values identifying reference objects that define a rectangular area. + +Unless the ```new critter mood``` is ```friendly```, mood is ignored on combat level 0. + + +### Action Type 22: Trap Message + +> Internally, such messages are called "barks". + +**Trap Message Action Details** (16 byte) + + 0000 sint32 Background image + 0004 int32 Message index (within resource 0x0867) + 0008 int32 Text colour + 000C int32 MFD suppression flag; 0: Show in MFD, 1: Show only in HUD + +The ```Background image``` and ```Text colour``` are only considered for display in MFD. The user needs to have messages set to "text" or "both" to see them. + +For the ```Background image```, the following values are possible: + + -1 SHODAN (black and white) + -2 Diego + +> The game uses other image references as well, yet they don't work. This is due to the code being modified to ignore positive background image values. + + +### Action Type 23: Spawn Objects + +**Spawn Objects Action Details** (16 byte) + + 0000 int32 Object type; Format 0x00CCSSTT + 0004 int32 Area + 0008 int32 Quantity - quest value key + 000C int32 Flags + +This action spawns a random number of objects, randomly distributed within an area. +The spawned objects are placed at the floor in the center of the tile. + +The ```area``` field specifies a rectangular area, either based on two objects, or a square. +If ```area``` is less than ```0x10000```, then it specifies half of the final side-length of the resulting square, and the trap object's location is at the lower-right corner of this square. Otherwise, ```area``` specifies two object indices that define the rectangle with their locations. + +Objects are only spawned if the ```Combat``` value is 1 or higher. If it is 3, then one more object than requested is spawned. +Not all classes can be spawned. +> Although the game uses enemies exclusively, ammo, explosives, and patches can be spawned as well. + +**Spawn Objects Flags** (4 byte) + + 0x00000001 Avoid spawn if tile is within a distance of 7 based on the sum of X and Y deltas. + 0x00000002 Avoid spawn if hacker looks at the tile within a distance of 14 based on the sum of X and Y deltas. + 0x00000004 Allow spawn if tile is an elevator tile (identified by elevator music). + 0x00000008 Force spawn, even if the ceiling is too low, or the tile contains already a critter. + + +### Action Type 24: Change Object Type + +> Internally, this action is called "transmogrify". + +**Change Object Type Action Details** (16 byte) + + 0000 int32 Object index + 0004 int16 New type + 0006 int16 Reset mask + 0008 [8]byte Unused + +This action sets, or toggles, the type of an object. It is primarily used to extend and retract force bridges. +> For bridges in their retracted state, the game uses "Elephant, Jorp" (```7/7/8```), an invisible placeholder type. + +The ```Reset mask``` is used when the specified object is already at given type. If the specified object is already at given type, +then the new type is calculated as ```New type``` XOR ```Reset mask```. +> So, to toggle between ```0x08``` and ```0x09```, ```New type``` should be ```0x08``` and ```Reset mask``` must be set to ```0x01```. +> To toggle between ```0x08``` and ```0x07```, ```New type``` should be ```0x07``` and ```Reset mask``` must be set to ```0x0F```. + +> The first byte of unused field at ```0008``` is set to ```1``` for only one force bridge in the game (on level 2) and is ignored. +> It appears that an earlier version of the code supported suppressing of animations. + diff --git a/modding/ss-specs/levelObjects/Conditions.md b/modding/ss-specs/levelObjects/Conditions.md new file mode 100644 index 0000000..202a44b --- /dev/null +++ b/modding/ss-specs/levelObjects/Conditions.md @@ -0,0 +1,66 @@ +## Conditions + +Conditions are filters for [actions](Actions.md) and for some [fixtures](09_Fixture/levelFixtureEntry.md) that don't use actions. + +Conditions can examine different aspects of the state of the game or (current) level. + + +### Game Variable Conditions + +These conditions check the value of a game variable (also called "quest value"). + +**Game Variable Condition** (4 bytes) + + 0000 uint16 Variable key + 0002 byte Value + 0003 byte Failure message + +The ```Failure message``` is a message (or indication) the player shall receive if the condition was not met. +```0x00``` means no indication, ```0xFF``` is used for security warnings (blocked by SHODAN security); +All other values are interpreted as text/audio message index. + + +**Variable key** (2 bytes) + + 0x03FF Variable index + 0x0C00 Unused + 0x1000 Variable type; 0: boolean variable, 1: int16 variable + 0xE000 Comparison + +**Comparison** + + 0: Variable must be equal to value + 1: Variable must be less than value + 2: Variable must be less than, or equal to, value + 3: Variable must be greater than value + 4: Variable must be greater than, or equal to, value + 5: Variable must be not equal to value + 6: Value must be greater than a random value of range 0..254 (variable value is ignored) + + +### Object Type Conditions + +These conditions check for a specific object type. + +**Object Type Condition** (4 bytes) + + 0000 int32 Class/Subclass/Type encoded as 0x00CCSSTT + +Note that the most significant byte needs to be ```0x00```. + +> These conditions are used for the quest items and for death watch trigger. + + +### Object Index Conditions + +These conditions are level-specific and only used for death watch trigger. + +**Object Index Condition** (4 bytes) + + 0000 int16 Object index + 0002 byte Unused + 0003 byte Condition marker = 0x01 + + +As these conditions are used for the same type of triggers as the object type conditions, the ```condition marker``` identifies the class of condition. + diff --git a/modding/ss-specs/levelObjects/GenericWeaponInfo.md b/modding/ss-specs/levelObjects/GenericWeaponInfo.md new file mode 100644 index 0000000..3437ede --- /dev/null +++ b/modding/ss-specs/levelObjects/GenericWeaponInfo.md @@ -0,0 +1,33 @@ +### Generic Weapon Info + +This structure is reused for various offensive items. + +**Generic Weapon Info** (`COMBAT_DATA_FIELDS` macro) (8 bytes) + + 0000 sint16 Damage + 0002 byte Offence value + 0003 byte Damage type + 0004 byte Special damage type + 0005 [2]byte Unused + 0007 uint8 Armour penetration + +The `offence value` is compared to the defence value of the target object. See [Common Object Properties](../fileFormat/PropertyFiles.md#common-table) for details. + +If `Armour penetration` is equal or higher than the `Armour` value of the object being hit, the full `Damage` is applied. Otherwise, the difference is subtracted from `Damage`. If the resulting value reaches 0 or lower, "No Damage" is displayed. + +`Special damage type` matches the `Special vulnerabilities` sub-fields from [Common Object Properties](../fileFormat/PropertyFiles.md#common-table); +It is split up into `0x0F` primary damage (double damage) and `0xF0` super damage (quad damage). + +> Similar to the vulnerabilities in the common object properties, the damage type is a single 32bit value from `0003` in the source. + + +**Damage Type Bitfield** (1 byte) + + 0x01 Explosion (impact) + 0x02 Energy + 0x04 Magnetic (EMP) + 0x08 Radiation + 0x10 Gas + 0x20 Tranquilizer + 0x40 Needle + 0x80 Bio diff --git a/modding/ss-specs/levelObjects/index.md b/modding/ss-specs/levelObjects/index.md new file mode 100644 index 0000000..f7a4012 --- /dev/null +++ b/modding/ss-specs/levelObjects/index.md @@ -0,0 +1,99 @@ +## Level Objects + +A level object is anything that is not part of the basic level layout (the tile map). The game knows about 476 different objects. + +Objects are identified using three values. They are divided by class, subclass and type. In this documentation such an identifier will be written as ```c/s/t```. For example, the energy charge station is ```9/2/1```. + +> In the original source, such an identifier is called a ```TRIPLE```. + +Information about level objects exists in two places: +* Static properties, describing an object in general +* Instance data, describing the state of a specific object instance in the game + +The static properties are stored in a separate file. See [Property Files](../fileFormat/PropertyFiles.md) for basic information. +Instance data is stored as part of an archive. See [Level Objects](../archives/levelObjects.md) for basic information. + +### Known Classes, Subclasses and Types + +The following table lists the object classes together with the available types per subclass: + +| Class | Name | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | +|:-----:|------------------------------------|----|----|----|----|----|----|----|----| +| 0 | Gun | 5 | 2 | 2 | 2 | 3 | 2 | - | - | +| 1 | Ammo | 2 | 2 | 3 | 2 | 2 | 2 | 2 | - | +| 2 | Physics | 6 | 16 | 2 | - | - | - | - | - | +| 3 | Grenade | 5 | 3 | - | - | - | - | - | - | +| 4 | Drug | 7 | - | - | - | - | - | - | - | +| 5 | Hardware | 5 | 10 | - | - | - | - | - | - | +| 6 | Software | 7 | 3 | 4 | 5 | 3 | - | - | - | +| 7 | Big Stuff | 9 | 10 | 11 | 4 | 9 | 8 | 16 | 10 | +| 8 | Small Stuff | 8 | 10 | 15 | 6 | 12 | 12 | 9 | 8 | +| 9 | Fixtures | 9 | 7 | 3 | 11 | 2 | 3 | - | - | +| 10 | Door | 10 | 9 | 7 | 5 | 10 | - | - | - | +| 11 | Animating | 9 | 11 | 14 | - | - | - | - | - | +| 12 | Trap | 13 | 1 | 5 | - | - | - | - | - | +| 13 | Container | 3 | 3 | 4 | 8 | 13 | 7 | 8 | - | +| 14 | Critter | 9 | 12 | 7 | 7 | 2 | - | - | - | + +> This information is not stored within the resource files, it is hardcoded in the engine. + + +### Static Properties + +The following table lists the byte sizes of the generic and specific property structures per class. +Many of these bytes are all zero in ```objprop.dat```; In these cases no detailed data definition will be linked. + +| Class | Generic | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | +|:-----:|---------------------------------------------|-----------|-----------|-----------|-----------|-----------|-----------|----|----| +| 0 | [2](00_Gun/gunProperties.md) | 1 | 1 | [16][0/2] | [13][0/3] | [13][0/4] | [18][0/5] | - | - | +| 1 | [14](01_Ammo/ammoProperties.md) | 1 | 1 | 1 | 1 | 1 | 1 | 1 | - | +| 2 | [1](02_Physics/physicsProperties.md) | [20][2/0] | [6][2/1] | 1 | - | - | - | - | - | +| 3 | [15](03_Grenade/grenadeProperties.md) | 1 | [3][3/1] | - | - | - | - | - | - | +| 4 | 17 | 7 | - | - | - | - | - | - | - | +| 5 | 2 | 1 | 2 | - | - | - | - | - | - | +| 6 | 2 | 1 | 1 | 1 | 1 | 1 | - | - | - | +| 7 | 4 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | +| 8 | 2 | 1 | 1 | 1 | 1 | 1 | [6][8/5] | 1 | 2 | +| 9 | 1 | 1 | 1 | 1 | 1 | 0 | 1 | - | - | +| 10 | 1 | 1 | 1 | 1 | 1 | 1 | - | - | - | +| 11 | [2](11_Animating/animatingProperties.md) | 1 | 1 | [1][11/2] | - | - | - | - | - | +| 12 | 1 | 1 | 1 | 1 | - | - | - | - | - | +| 13 | 3 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | - | +| 14 | [75](14_Critter/critterProperties.md) | 1 | 2 | 2 | [6][14/3] | 1 | - | - | - | + + +> There is also the case of objects with a specific entry length of 0. This is the case for object types that didn't make it into the game (vending machines). +> Though, this is only a guess. Yet, the serialization code of the source-port (based on the mac-port) shows a hack by shifting the offset back by 2 bytes after serializing the doors. So, something must have been done in the original engine. + +[0/2]: 00_Gun/gunProperties.md#specific-2-properties +[0/3]: 00_Gun/gunProperties.md#specific-3-properties +[0/4]: 00_Gun/gunProperties.md#specific-4-properties +[0/5]: 00_Gun/gunProperties.md#specific-5-properties +[2/0]: 02_Physics/physicsProperties.md#specific-0-properties +[2/1]: 02_Physics/physicsProperties.md#specific-1-properties +[3/1]: 03_Grenade/grenadeProperties.md#specific-1-properties +[8/5]: 08_SmallStuff/smallStuffProperties.md#specific-5-properties +[11/2]: 11_Animating/animatingProperties.md#specific-2-properties +[14/3]: 14_Critter/critterProperties.md#specific-3-properties + + +### Object Names + +The names are localized and [stored as texts](../media/Texts.md). The short names are stored in resource ```0x086D```, the long +names are in resource ```0x0024```. + + +### Object Bitmaps + +Every object has bitmaps associated with it. The bitmaps are all stored in resource ```0x0546``` in ```objart.res```, starting with block 1 for the first object. Each object has at least 3 bitmaps associated, with additional in-world bitmaps specified as per ```Extra``` field of the [Common Object Properties](../fileFormat/PropertyFiles.md#common-table). + +As a result, to determine the proper block number for a given object, the total number of previous (extra) bitmaps needs to be known. + +The first image is the standard image, the second (and additional) bitmaps are in-world bitmaps, and the last one is a micro-image. + +> The micro-images are sometimes fully transparent. This has been found for marker objects for instance. +> Maybe a (future) mapping unit would have been able to show objects, except those that weren't meant to be seen by the player. +> For such transparent bitmaps, the first bitmap shows some abstract presentation. For example, the "continuous trigger" has the infinity symbol as first image. +> On the contrary, using the first bitmap exclusively for editors might not be usable either as they sometimes have the same image for different types (software instance). + +Objects of render type ```Fragment``` have one extra bitmap. The first one contains the colour information and the extra bitmap the depth information; It is a "gray-scale" bitmap with ```0xD0``` as the center. diff --git a/modding/ss-specs/media/Bitmaps.md b/modding/ss-specs/media/Bitmaps.md new file mode 100644 index 0000000..07b878a --- /dev/null +++ b/modding/ss-specs/media/Bitmaps.md @@ -0,0 +1,97 @@ +## Bitmaps + +Bitmaps are images using a palette of colours. System Shock uses bitmaps exclusively for its graphics and uses one byte +as index per pixel - thus 256 colours possible. The resources store such bitmaps with content type ```0x02``` and they are always compound, even if only one bitmap is stored. + +Bitmaps are stored with a header, the pixel data (sometimes compressed), and an optional private palette. + +### Header + +**Bitmap Header** (`FrameDesc` struct) (28 bytes) + + 0000 [4]byte Unused. Always zero. (used as pointer in the engine) + 0004 byte Type -- 0x02: Uncompressed; 0x04 Compressed + 0005 byte Align -- Unused + 0006 uint16 Flags + 0008 sint16 Width + 000A sint16 Height + 000C uint16 Stride + 000E byte WidthFactor + 000F byte HeightFactor + 0010 [4]int16 HotspotBox: Left, Top, Right, Bottom + 0018 sint32 Private palette offset + +The ```Flags``` is set to value ```0x0001``` then palette index ```0x00``` should be treated as a transparent pixel instead of painting it black. +> Compressed images appear to default to transparency if the resolution of the game is 320x200. Higher resolutions require the flag set to behave identical. +> +> Other factors may also govern whether the bitmap should be treated with transparency - videos and level textures have their special handling in this regard. + +The ```WidthFactor``` and ```HeightFactor``` specify the index of the highest set bit of ```Width``` and ```Height``` +respectively. For example, if Width is 320 (0x140), then the WidthFactor is 8 (1<<8 == 0x100). + +`HotspotBox` is a placeholder for different applications. It can define a rectangle, or it only defines a point (using the first two values) for anchoring. + +> For the existing images in the original resource files, all images have a ```Stride``` equal to ```Width```. + +### Pixel Data + +After the header, the pixel data is serialized. If the image is uncompressed (Type ```0x02```), +```Height``` x ```Stride``` bytes make up the image. Compressed images (Type ```0x04```) use a simple run-length-encoding (RLE). + +#### Pixel Compression + +The compressed byte-stream contains the following byte sequences: + + 00 nn zz write nn bytes of value zz + nn .. 0 < nn < 0x80: copy the following nn bytes + 80 lo hi lo hi are a little-endian uint16 nnnn, to be handled as follows: + 80 00 00 End of compressed data (Skip to end) + 80 lo hi 0 < hi < 0x80: skip (nnnn & 0x7FFF) output pixel + 80 lo hi .. 0x80 <= hi < 0xC0: copy the following (nnnn & 0x3FFF) bytes + 80 lo C0 hi == 0xC0: undefined (not encountered) + 80 lo hi zz 0xC0 < hi: write (nnnn & 0x3FFF) bytes of value zz + nn 0x80 < nn: skip (nn & 0x7F) output pixel + +Three cases exist to compress pixel data: skip a list of pixel, compress a list of a constant +value and copy an arbitrary array of bytes. For each of the three, two encoding variants are used: a short and a long form. +If a count is to be serialized that is higher than the long-form can store, the encoder uses the long-form to reduce this number to finalize with the short-form. + +> Skipping output pixel is relevant for compressing video clips. Skipped pixel will retain the colour value from the previous frame. +> For still images, initialize the output buffer with 0x00. +> +> To implement a compressor that creates output closest to the original content, use the following sequence. The term "reference" means either a previous frame bitmap, or a fictual frame with all bytes 0x00. +> * From an array of bytes with length X, calculate the count Z of bytes at the end that are identical to reference. Compress only N bytes, where N = X - (Z % 0x7FFF). +> The final sequence ```80 00 00``` implies to skip to the end. The encoder +> seems to have had a limit of counting only up to 0x7FFF, so there may be further (long) skip entries before. +> * Iterate through the pixel data from start to N: +> * Count the extent of static value bytes +> * Count the number of bytes identical to reference +> * Determine encoding method +> * If the pixel value shall be retained from the reference, skip the count of bytes +> * Otherwise, if the count is more than three, encode the count and the constant value +> * Otherwise, count the extent of an arbitrary array; Abort when more than three static or identical values are found in sequence; Encode this array. +> * Skip the input bytes that have been serialized and continue with the loop +> * Encode end of compressed data sequence. + + +### Private Palette + +The format also allows for an optional palette, which is specific for the bitmap. If an image has no private palette, then this +entry is not present and the resource data ends directly after the pixel data. +Without a private palette, the colours of the bitmap are context specific. See [Palettes](Palettes.md) for more information. + +Private palettes are used for the splash screens at the beginning of the game, where nothing else is shown. +The ```Private palette offset``` in the bitmap header specifies the absolute offset *from the beginning* of the resource to the private palette entry. This means in case of a compound resource, this offset measures across the previous resource blocks as well. + +**Private Palette** (772 bytes) + + 0000 [4]byte Unknown. Always 0x00, 0x00, 0x00, 0x01 + 0004 [256*3]byte RGB values + +> The only working examples for this can be found in ```splash.res```. +> +> The resource file ```gamescr.res``` also contains three bitmaps with a private palette, but these palettes are not used and are ignored by the engine. +> The three bitmaps in question are the help overlay (```ALT+o```), and are shown with the game world in the background. Since in these +> cases the world can't change its colour, the screen can't use a private palette. By extension, the side-effects of the "Berserk" patch +> also affect the colours of the help overlay. +> Furthermore, for these three bitmaps, the values of offset fields are wrong as well. diff --git a/modding/ss-specs/media/Fonts.md b/modding/ss-specs/media/Fonts.md new file mode 100644 index 0000000..cf7681d --- /dev/null +++ b/modding/ss-specs/media/Fonts.md @@ -0,0 +1,33 @@ +## Fonts + +This chapter documents the data format of the fonts. The game uses [bitmap fonts](https://en.wikipedia.org/wiki/Computer_font#Bitmap_fonts) which describe the characters. + +The fonts are stored in single blocks within resources of type ```0x03```. They can be found in the file ```gamescr.res```. + +All glyphs in the bitmap are lined up in a single line. There are no Y offsets per glyph, and the height is always equal to the height of the bitmap. An array of ```X-Offsets``` points to the start of a glyph and the width is equal the difference to the next offset. + +### Binary Format + +**Font Header** (84 bytes) + + 0000 uint16 Font Type -- 0x0000: Monochrome, 0xCCCC: Color + 0002 [34]byte Unknown -- always 0x00 + 0024 int16 First character index (inclusive) + 0026 int16 Last character index (inclusive) + 0028 [32]byte Unknown -- always 0x00 + 0048 int32 Offset to X-Offsets, from beginning of data + 004C int32 Offset to bitmap, from beginning of data + 0050 int16 Width of bitmap in bytes + 0052 int16 Height of bitmap in bytes + +**X-Offsets** (2*N bytes) + +The X-Offsets is an array of ```int16``` values. The length of the array is equal to the number of characters the font covers, plus one. The width of one glyph is always equal the difference to the next offset, even for the last one. + +For a monochrome font, the offset is counted in bits, always beginning with the MSB. + +**Bitmap** (W*H bytes) + +Monochrome fonts have the glyphs packed as bits within the bytes, color fonts use the bytes as palette indices. + +> Fonts that have an outline, primarily seen with the HUD fonts, don't store the outline information in the font bitmaps. The outlines are rendered at runtime. diff --git a/modding/ss-specs/media/Geometry.md b/modding/ss-specs/media/Geometry.md new file mode 100644 index 0000000..4492627 --- /dev/null +++ b/modding/ss-specs/media/Geometry.md @@ -0,0 +1,182 @@ +## 3D Object Geometry + +This chapter documents the data format of the three dimensional objects encountered in the game (tables, ICE in cyberspace, crates...). + +All the geometry is stored in the file ```obj3d.res``` with content type ```0x0F```. Each resource in this file is compound with one data block. There is one object per resource (and data block). + +Each three dimensional ```model``` consists of a list vertex definitions and a structure of ```nodes```, with the model itself being the root node. A node contains a list of ```anchors```, with each one having a normal vector and a reference point. +Up to exactly one anchor per node may branch out to two further nodes (creating a binary tree). Further anchors contain lists of rendered ```faces```. Faces are either texture mapped or filled with a single colour. + +### Binary Format + +The data starts with 8 bytes of header information, followed by a list of "commands" which describe the structure. + +**Geometry Header** (8 bytes) + + 0000 int16 Unknown -- always 0x0027 + 0002 int16 Unknown -- always 0x0008 + 0004 int16 Unknown -- always 0x0002 + 0006 int16 Total number of Faces + +> The ```Total number of Faces``` value does not seem to be authorative. There are a few entries in the resouce file which state a higher count than actually in the model. + +**Model Command** (2+N bytes) + + 0000 int16 Model Command Identifier + 0002 N Command specific content + +> There is no framing structure which would allow skipping command entries, so an implementation has to know of all commands beforehand. + + +**fixed32/16** (4 bytes) + +In the following, coordinates (and their offsets) are serialized as fixed floating point values in a 32/16 format. + + 0000 sint16 Integer value + 0002 uint16 Fraction (in 1/65536 units) + +> For a simple implementation, it is sufficient to read the value as a ```sint32```, convert it to floating point and divide by 65536.0 . + + +**Vector** (12 bytes) + +A vector is serialized with three coordinates: + + 0000 fixed32/16 X + 0004 fixed32/16 Y + 0008 fixed32/16 Z + + +### Geometry Commands + +#### Vertex Definitions +Vertices are always (and only) defined at the beginning of the model (the root node). Three ways exist: define a single vertex, multiple vertices or references to previous ones with offsets. + +##### Define Single Vertex + + 0000 int16 Model Command Identifier -- 0x0015 + 0002 int16 Unknown -- always 0x0000 + 0004 Vector Vertex coordinates + +##### Define Multiple Vertices + + 0000 int16 Model Command Identifier -- 0x0003 + 0002 int16 Number of vertices + 0004 int16 Unknown -- always 0x0000 + 0006 [N]Vector Coordinates of vertices + +##### Define Vertex (One Offset) + + 0000 int16 Model Command Identifier -- X: 0x000A, Y: 0x000B, Z: 0x000C + 0002 int16 New vertex index + 0004 int16 Reference vertex index + 0006 fixed32/16 Offset value to coordinate as per command identifier + +> In the existing files, the ```New vertex index``` has always been found to equal the current amount of known vertices, appending at the end of the list. + +##### Define Vertex (Two Offsets) + + 0000 int16 Model Command Identifier -- XY: 0x000D, XZ: 0x000E, YZ: 0x000F + 0002 int16 New vertex index + 0004 int16 Reference vertex index + 0006 fixed32/16 Offset value to first coordinate as per command identifier + 0010 fixed32/16 Offset value to second coordinate as per command identifier + +> In the existing files, the ```New vertex index``` has always been found to equal the current amount of known vertices, appending at the end of the list. + +> To generate a list of vertex definitions as close as possible to the original files, follow these steps: +> * Determine count of dissimilar vertices: +> * From the list of vertices, iterate reverse from the tail. +> * For each vertex, search the list below the current index if there is another vertex which has at least one identical coordinate. +> * Abort this loop if the current vertex has no similar other. Vertices from beginning to the current index are all dissimilar. +> * Depending on the count of dissimilar vertices, define them with either the single definition or the multiple definition. +> * Now iterate forward through the list (until end), beginning with the first vertex after the dissimilar ones. +> * For each vertex, again find another most similar from those coming before it (including those already handled in this loop). +> * Compare the coordinates again. +> * If two are identical, write a single offset definition (for the one coordinate not equal). +> * If one is identical, write a double offset definition (for the two coordinates not equal). +> +> This list assumes the vertices are all unique and they were sorted to have the dissimilar ones come first. This must be done in +> combination with modifying the face definitions as they refer to the vertices via indices. +> Following this sequence will create a vertex definition with equal byte length as in the original. The differences to the original files are in which +> reference vertices were taken. + +#### Node Control + +##### End Of Node + + 0000 int16 Model Command Identifier -- 0x0000 + +Every node is finished with this command. + +##### Define Node Anchor + + 0000 int16 Model Command Identifier -- 0x0006 + 0002 Vector Normal vector + 000E Vector Reference point + 001A int16 Byte offset to 'left' branch + 001C int16 Byte offset to 'right' branch + +The offset values are based on the start of the command. Branch data comes only after the current node has ben finished; There is always only one ```End Of Node``` command pending. Branches are serialized depth-first. + +> In the existing files always the 'right' branch has been found to be serialized first. Although only a few cases, several layers can be found within one branch. If a branch is not needed, it contains only the ```End Of Node``` command. + + +#### Face Control + +##### Define Face Anchor + + 0000 int16 Model Command Identifier -- 0x0001 + 0002 int16 Anchor byte length (measured from start of this command) + 0004 Vector Normal vector + 0010 Vector Reference point + 001C N Face drawing commands + +Faces within one ```Define Face Anchor``` command are either painted in a single colour (with an optional shade) or texture mapped. + +##### Coloured Face + + 0000 int16 Model Command Identifier -- 0x0004 + 0002 int16 Vertex count + 0004 [N]int16 Vertex indices + +This command always has either a ```Set Colour``` or a ```Set Colour And Shade``` command before. It may be that several ```Coloured Face``` commands refer to the same setting. + +##### Set Colour + + 0000 int16 Model Command Identifier -- 0x0005 + 0002 int16 Colour palette index + +##### Set Colour And Shade + + 0000 int16 Model Command Identifier -- 0x001C + 0002 int16 Colour palette cross index + 0004 int16 Shade value (0..3) + +The ```Colour palette cross index``` refers to a small lookup table from the corresponding static object properties. This lookup table is a list of always 6 palette indices. + +> The data files contain cross indices of 0..6 . TSSHP skips those with value 0 and subtracts 1 from the remaining 6 values. + +##### Texture Mapping + + 0000 int16 Model Command Identifier -- 0x0025 + 0002 int16 Number of entries + 0004 [N]Entry Mapping entries + +**Mapping Entry (10 bytes)** + + 0000 int16 Vertex index + 0002 fixed32/16 texture u coordinate + 0006 fixed32/16 texture v coordinate + +##### Textured Face + + 0000 int16 Model Command Identifier -- 0x0026 + 0002 int16 Texture identifier + 0004 int16 Vertex count + 0006 [N]int16 Vertex indices + +This command always has a ```Texture Mapping``` command before. + +The ```Texture identifier``` references a texture with a resource identifier base of ```0x01DB```, found in ```citmat.res```. +The value of 0 specifies special, object specific handling. diff --git a/modding/ss-specs/media/Palettes.md b/modding/ss-specs/media/Palettes.md new file mode 100644 index 0000000..e9e26f7 --- /dev/null +++ b/modding/ss-specs/media/Palettes.md @@ -0,0 +1,40 @@ +## Palettes + +The engine uses a graphic mode with 256 colors per pixel. The graphic resources (images and videos) use bitmaps together +with palette information to resolve a pixel value (1 byte) to the corresponding RGB value (3x1 byte). + +Depending on the game-state, different palettes are in use. The splash screens at the beginning are created using a +```Private Palette``` (see the [bitmap](Bitmaps.md) format). Cutscenes have their own palette(s) and during the game, +one specific palette is used throughout. This game palette is stored in ```gamepal.res```. + +### Format + +Palettes always have a size of 768 bytes, for 256 colors with 1 byte for each of the three colour channels. + +**Palette** (768 bytes) + + 0000 [256*3]byte RGB values + +### Transparency + +Palette index 0 is used as a marker for fully transparent pixel. For instance, various decoration or critter sprites need +to have transparent areas in order to lose the rectangular shape. + +### Pixel Animation / Palette Looping + +During the game various textures can be observed with small animations. Most typically seen with LED-like blinking effects. +Instead of implementing this with multiple frames, the engine simply loops through the colors within a few groups. + +The following groups (palette index values) are looped: + + 0x03 - 0x07: Changed every ~1200 msec + 0x0B - 0x0F: Changed every ~700 msec + 0x10 - 0x14: Changed every ~360 msec + 0x15 - 0x17: Changed every ~1800 msec + 0x18 - 0x1A: Changed every ~1430 msec + 0x1B - 0x1F: Changed every ~1080 msec + +> These groups are looped even while the game is paused. +> +> Above switching times have been determined by counting frames of a video recording from dosbox, running within a VM. +> Exact values may be different. diff --git a/modding/ss-specs/media/SoundEffects.md b/modding/ss-specs/media/SoundEffects.md new file mode 100644 index 0000000..7a0e34a --- /dev/null +++ b/modding/ss-specs/media/SoundEffects.md @@ -0,0 +1,45 @@ +## Sound Effects + +Sound effects are stored in ```digifx.res```, which contains resources of data type ```0x07```. +The resources have IDs in the range from ```0x00C9``` to ```0x013B```, are uncompressed and have one effect per resource. + +Each resource contains one sound effect, which is stored in the +[Creative Voice File](http://wiki.multimedia.cx/index.php?title=Creative_Voice) format. + +Although the format would allow for different sample formats, only unsigned 8-bit PCM audio (MIME type ```audio/L8```) +is supported and expected. The sound effects don't use any special block types of the file format, +only one ```Sound data``` block is present, followed by a ```Terminator``` block identifier. All effects have one channel (mono). + +### Format +The following is an extract of the above linked page, specialized to the use by the engine. + +***Sound Effect*** + + 0000 [19]byte Identifier text "Creative Voice File" + 0013 byte Identifier terminator: 0x1A + 0014 int16 Length of header (commonly 0x1A) + 0016 uint16 Version number (0x010A) + 0018 uint16 Version number validation + + 001A byte Sound data block type: 0x01 + 001B [3]byte Length of block data (sample count + 2) + + 001E byte Frequency divisor + 001F byte Codec ID (audio/L8: 0x00) + 0020 []byte Samples + + NNNN byte Terminator block type: 0x00 + +***Version Number*** + +The version number is encoded as a major and minor number, in the high and low bytes respectively. + +The validation value is the result of building the binary complement of the version number and adding the constant ```0x1234```. + +***Frequency Divisor*** + +The frequency divisor is used to calculate the sample rate with the following formula: + +``` +sampleRate := 1000000 / (256 - frequencyDivisor) +``` diff --git a/modding/ss-specs/media/Texts.md b/modding/ss-specs/media/Texts.md new file mode 100644 index 0000000..611d604 --- /dev/null +++ b/modding/ss-specs/media/Texts.md @@ -0,0 +1,67 @@ +## Texts + +All texts are stored in the language specific files ```cybstrng.res```, ```frnstrng.res``` and ```gerstrng.res```. +These files contain 188 resources of type ```0x01```. They are compound with several blocks. Each block contains +one string, which is always 0x00 terminated. + +While the English strings may be readable according to standard ASCII, with the French and German options, texts +need to be interpreted according to the DOS [code page 437](http://en.wikipedia.org/wiki/Code_page_437). + +> Since texts are always presented through the game fonts, any one-byte codepage can be used - As long as the +> corresponding font bitmaps match for the requested characters (bytes). + +### Control Characters + +For texts with multiple lines, ```0x0A``` acts as a newline marker. + +```0x02``` is a soft-hyphen between syllables. Not all texts contain this character. + +### Resource Overview + +* 0x0867 Trap messages, one per block +* 0x0868 Wall texts +* 0x0869 Switch/Panel descriptions +* 0x086A Texture descriptions (Note: From block 273 onwards of the 512 blocks, the text is always "bug") +* 0x086B "Can't use X" texts for textures (Note: bug texts are simply "can't be used") +* 0x086C HUD texts/messages +* 0x086D Object short names +* 0x086E Messages of the targeting unit +* 0x086F HUD messages (top) +* 0x0870 Log category titles ("Level 1 Logs"...) +* 0x0871 Action messages +* 0x0872 Intro cutscene subtitles +* 0x0873 Death cutscene subtitles +* 0x0874 Win cutscene subtitles +* 0x0875 System scanner label texts +* 0x0876 Sign/Logo names +* 0x0877 Monitor/Screen texts +* 0x0878 Cyberspace toggle texts +* 0x0879 Access card names +* 0x087A Datalet messages (cyberspace nodes) +* 0x087B Menu texts +* 0x087C Credits +* 0x087D Keyboard help +* 0x087E Various HUD messages +* 0x087F Tooltip texts +* 0x0880 Main menu texts +* 0x0881 Endgame statistics texts +* 0x0882 Inventory description/version texts +* 0x0883 Texts for MFD games + +* 0x0024 Object names (readable) + +* 0x003C - 0x0046 Paper text messages + +#### [Electronic Messages](../content/ElectronicMessages.md) + +* 0x0989 - 0x09B6 E-Mails +* 0x09B8 - 0x09BD Level R logs +* 0x09C8 - 0x09D7 Level 1 Logs +* 0x09D8 - 0x09E4 ... +* 0x09E8 - 0x09F0 +* 0x09F8 - 0x09FE +* 0x0A08 - 0x0A13 +* 0x0A18 - 0x0A21 +* 0x0A28 - 0x0A30 +* 0x0A38 - 0x0A3D Level 8 logs +* 0x0A98 - 0x0AA4 Data fragments diff --git a/modding/ss-specs/media/VideoClips.md b/modding/ss-specs/media/VideoClips.md new file mode 100644 index 0000000..7eb8242 --- /dev/null +++ b/modding/ss-specs/media/VideoClips.md @@ -0,0 +1,57 @@ +## Video Clips + +Video clips are made out of a sequence of standard [bitmaps](Bitmaps.md). The meta-information about a video clip +is stored in resources of type ```0x04```. This information block refers to the resource that contains the frames, which +is stored in the same resource file. + +Contrary to [MOVI Resources](moviResources.md), video clips contain only the frame information - nothing else. + +### Format + +**Video Clip Sequence Description** (16+5*N bytes) + + 0000 int16 Width of video. + 0002 int16 Height of video. + 0004 int16 Resource ID of frames, referring to a compound resource + 0006 [6]byte Unknown. Always 0x00 + 000C int16 Unknown. TriOp intro: 0x0001, 0x0000 otherwise + 000E N*[5]byte Sub-table of N sequence entries + MMMM int16 Unknown. Always 0x010C + +**Video Clip Sequence Entry** (5 bytes) + + 0000 byte Unknown. Always 0x04. + 0001 byte First frame + 0002 byte Last frame + 0003 int16 Frame time (in milliseconds) + + +### Video Mails + +Video mails are small animated movies sent as mail during the game, all stored in ```vidmail.res```. + +A single video mail is split up in several sequences. Most notably, the TriOp intro is a reused sequence for all mails. +The resource file contains 12 such sequences: Per sequence there is one resource of type ```0x04``` +and a corresponding bitmap resource (containing the frames in blocks). + +Video mails have a size of 200x100 pixel and are the same for the HD and CD releases. They share the palette of the +game (```gamepal.res```). + +#### Hardcoded constants + +The classic engine has the following hardcoded constants: + +* There are 6 video mails, ordered as: "shield", "grove", "bridge", "laser", "explode", and "status" +* "laser" has two sequences, "explode" has five sequences. The twelth sequence is the intro, having index 10, right before the status. +* The first ID for the sequence descriptions is `0x0A4C` +* The first ID for the sequence bitmaps is `0x0A40` + +> The engine always plays the intro first, then the requested video mail. The bundling of sequences (like for "explode") is hardcoded, +> so if another video mail could either split itself up, or simply have only one frame (or none at all) at the end. + +### Cutscenes + +The files ```start1.res```, ```death.res``` and ```win1.res``` contain low-res variants of the corresponding +cutscenes. + +The palettes for these cutscenes are stored in ```cutspal.res```. diff --git a/modding/ss-specs/media/moviResources.md b/modding/ss-specs/media/moviResources.md new file mode 100644 index 0000000..486044c --- /dev/null +++ b/modding/ss-specs/media/moviResources.md @@ -0,0 +1,258 @@ +## MOVI Resources + +Resources with content type `0x11` are a media container themselves, similar to audio/video container formats. +These resources are called "MOVI Resources" since the initial tag is the four letter code `MOVI`. + +### Format + +Such a resource contains the following components: +* a header, describing meta information about the media, +* a palette, with initial colours, +* an index, describing the following data entries, and +* the data entries. + +**MOVI Header** (`MovieHeader` struct) (1024 bytes) + + 0000 uint32 magicId Container tag 0x49564F4D ("MOVI") + 0004 int32 numChunks Number of chunk entries in file + 0008 int32 sizeChunks Size of index table + 000C int32 sizeData Size of content (all data entries) + 0010 fix totalTime Length of media (in seconds) + 0014 fix frameRate Frame rate (informational only) + 0018 int16 frameWidht Width of video + 001A int16 frameHeight Height of video + 001C int16 gfxNumBits For videos may be 0x0008, 0x000F or 0x0018, 0x0000 otherwise + 001E int16 isPalette Palette presence in file. For videos 0x0001, 0x0000 otherwise + 0020 int16 audioNumChans Numbers of audiochannels 0x0000 - no audio, 0x0001 - mono, 0x0002 stereo + 0022 int16 audioSampleSize Audio sample size, 0x0001 - 8-bit, 0x0002 - 16-bit + 0026 fix audioSampleRate Sample rate of audio, in Hz (0x2B770000 - 11127 Hz, 0x56EE0000 - 22254 Hz) + 0030 uint8[216] reserved Reserved + 0108 uint8[768] palette Palette + +If the index table size is greater than the total of index entry number times one entry size, the remaining bytes are zero. + +> The index table sizes come in multiples of 0x0400. The rough rule for recreating the existing index sizes is: +> Required size less than 0x0400 -> Use 0x0400. Otherwise: Round up to next 0x0400 border and add another 0x0400. + +**Palette** (256 * 3 bytes) + +The palette is always present, even if the MOVI contains only audio. In such cases all the colour channels are 0x00. + +**Movie chunk Entry** (`MovieChunk` struct) (8 bytes) + + 0000 uint32 Chunk info (bitfield) + 0004 uint32 offset Offset to data (from beginning) + +First 4 bytes are uint32 bit field which follow structure: + + bits 00-23 24 time Fixed point timestamp from movie start (in seconds) + bits 24-24 1 played Has this chunk been clocked out? (Used by engine internally) + bits 25-28 4 flags Chunk type specific flags + bits 39-31 3 chunkType Chunk type (see below) + +**Chunk type specific flags** may be: + +For Video chunk type (0x01 MOVIE_CHUNK_VIDEO) + + 0x0F MOVIE_FVIDEO_BMTMASK 4 bits of flags is bmtype + 0x0F MOVIE_FVIDEO_BMF_4X4 4x4 movie format + +> Not clear, why those flags uses same code, this may be bug or leftovers from reusing library component + +For Palette chunk type (0x04 MOVIE_CHUNK_PALETTE) + + 0x00 MOVIE_FPAL_SET Set palette from data + 0x01 MOVIE_FPAL_BLACK Set palette to black + 0x07 MOVIE_FPAL_EFFECTMASK 4 bits of flags is effect + 0x08 MOVIE_FPAL_CLEAR If bit set, clear screen + +For Table chunk type (0x05 MOVIE_CHUNK_TABLE) + + 0x01 MOVIE_FTABLE_COLORSET Table is color set + 0x02 MOVIE_FTABLE_HUFFTAB Huffman table (compressed) + +### Chunk Types + +#### 0 End of chunk (MOVIE_CHUNK_END) + +This type is always present as the last entry in the index. It is used to calculate the length of the previous entry as +the table does not contain a length field for entries. + +The timestamp of this entry should match the media length from the header. + +#### 1 Video (MOVIE_CHUNK_VIDEO) + +Video frames are compressed in two variants, for low and high resolution. + +Frames are either "full" frames, which set all, or nearly all, pixels in the buffer, typically at a scene change. Other frames are "delta" frames, which re-use (most of) the previous pixel in the buffer. This re-use is done by writing/using the palete index 0x00, which is meant to be transparent. + +##### Low-resolution Video + +Low-resolution video is using type `0x21`. Data contains a bounding box and then the compressed frame using the [compression algorithm](./Bitmaps.md#pixel-compression) for standard bitmaps. + +**Low Resolution Video Data** (8+N bytes) + + 0000 [4]int16 Bounding Box + 0008 []byte Compressed pixel data + +> In the existing videos, the bounding box is always `0, 0, 320, 150`, specifying the same size as the video itself. + +##### High-resolution Video + +High-resolution video is using type `0x79` with a more complex compression algorithm. This algorithm works in conjunction of the `Table` types. See [below](#high-resolution-video-compression) for details on the algorithm. + +**High Resolution Video Data** (2+N bytes) + + 0000 int16 Offset to Mask-stream in bytes, from beginning (XXXX) + 0002 []byte Bitstream + XXXX []byte Mask-stream + +The `Bitstream` is a stream of serialized big-endian integers. The `Mask-stream` is a stream of serialized little-endian integers of different lengths (2, 4, 6, and 8 bytes). + +The order and layout of the values in the streams is determined by the algorithm. The decoder will want to read past the end of the streams in a few cases. For these cases the missing bits/bytes are to be set to zero. The encoder removed any trailing 0x00 bytes from these streams in order to save further space. + +#### 2 Audio (MOVIE_CHUNK_AUDIO) + +The data entry contains unsigned 8-bit PCM samples. + +> Audio is chunked into entries of 8192 bytes, even if the MOVI contains only audio. + +#### 3 Text (MOVIE_CHUNK_TEXT) + +**Subtitle Entry** (`MovieTextItem` struct) (16+N bytes) + + 0000 uint32 tag Subtitle Control + 0004 uint32 offset Offset to text string, always 0x10 + 0008 [8]byte Unused -- always 0x00, as they are skipped + 0010 []byte Null terminated string + +Subtitle text will be shown with the color from palette index 255 (last entry). + +##### Subtitle Controls + +`'AREA'` (uint32 `0x41455241`) is for setting up the subtitle space. It defines the rectangle, in pixel (left, top, right, bottom), where the text is to be displayed. Example: + +``` +20 365 620 395 CLR +``` + +`'STD '` (uint32 `0x20445453`), `'FRN '` (uint32 `0x204E5246`) and `'GER '` (uint32 `0x20524547`) specify the actual subtitle text in the respective language, with `'STD '` being the default language (English). + +#### 4 Palette (MOVIE_CHUNK_PALETTE) + +There are two palette entry types: `0x4C` and `0x04`. For any change in the palette, two entries are in the container. Both entries always come in sequence (first `0x4C`, then `0x04`) and both have identical timestamp values. + +The entry with type `0x4C` will have a length of 0 and the same data offset as the next entry, `0x04`. +The entry with type `0x04` points to data of 0x300 bytes (= 256*3) which describes the new palette for upcoming frames. + +> At least for high-resolution videos, the next frame after a palette change does not always entirely set the complete pixel buffer for the next frame. +> In the intro video for instance, SHODAN's gaining of conscience scene has a few pixel left over from the previous scene. +> +> As a recommendation, a decoder should wipe the current frame buffer with 0x00 before applying a new palette/drawing further frames. +> An alternative could be to remap all pixel values with palette indices as close as possible to the new palette, though this might alter the resulting frame too much. + +#### 5 Table (MOVIE_CHUNK_TABLE) + +Table entries are used in combination with video type `0x79` entries for high-resolution videos. There are two table types: +* `0x05`: Palette lookup list +* `0x0D`: Control table + +See [below](#high-resolution-video-compression) for details on the algorithm. + +Such entries are common to several frames. For any change, two entries are in the container. Both entries always come in sequence (first `0x05`, then `0x0D`). + +> In the existing files, a change of tables is always followed by a change of palette. The exception is the first scene, which uses the initial palette from the MOVI header. + +##### Palette lookup list + +This entry, with type `0x05` is a simple lookup list. Every entry of type byte is a palette index. +This list is truncated as well. The decoder will want to read past the end of this stream, in which case zero bytes must be returned. + +##### Packed Control Words + +Table type `0x0D` contains a runlength encoded list of control words. + +**Packed Control Words** (4+(N*4) bytes) + + 0000 int32 Unpacked size + 0004 []Packed Packed entries + +The `Unpacked size` field equals the number of control words, times three (specifying the size in bytes). +The remainder of the entry is then the list of packed control words, from which the actual list of control words is unpacked. + +**Packed Entry** (4 bytes) + + 0000 byte Word count + 0001 uint24 Control Word + +The field `Word count` specifies how often the `Control Word` shall be extracted. A value of 0 is never encountered. + +### High-resolution Video Compression + +High-resolution video compression uses scene-specific tables shared amongst several frames and frame-specific bit- and mask-streams. Frames in this compression are made up of tiles with a size of 4x4 pixel each. For the high-resolution videos of 600x300 pixel this means 150x75 tiles. Tiles are coloured according to different types of control words, from left to right, top to bottom. + +**Control Word** (3 bytes, masked) + + 0xF00000 Count value + 0x0E0000 Type + 0x01FFFF Parameter + + +As a rough guideline, the frame-specific bit-stream provides index values into the control word table. According to the type of the control word, the current tile is coloured either directly, or with the additional help of the mask-stream and palette lookup list. + +A note on the bit-stream: Reading from this stream does not automatically advance the current position. Advancing the bit-stream is a separate operation, which may advance with a different number of bits than were previously read. This may cause re-use of some bits to increase compression. + +As noted above, the bit- and mask-stream, as well as the palette lookup list, have possible trailing zero values removed in the files. When the decoder wants to read beyond their end, they must provide values set to 0. + + +#### Decoder sequence + +A possible decoder is implemented using the following sequence: + +* Iterate over the tiles, outer loop is the rows, inner loop the columns. Finish the loops if either all tiles are covered, or the bit-stream has been exhausted. The stream is exhausted if the current position is at the end of or beyond the available range. +* Read a 12-bit value from the bit-stream. Use this value as an index into the control word table. +* If the `Count value` of the control word is zero + * A `Count value` of zero indicates a `Long Offset`. In this case, the `Type` and `Parameter` fields are taken together to form a 20-bit `Long Offset` value. + * Advance the bit-stream by 8 bits + * Enter a loop as long as the current control word indicates a `Long Offset`. + * Advance the bit-stream by 4 bits + * Read a 4-bit value from the bit-stream. Add this value to the `Long Offset` of the current control word and use the result as another index into the control word table. The retrieved entry becomes the new current control word. +* Advance the bit-stream by the `Count` value of the current control word. This may indicate a different amount of bits than previously read, even less. +* If the type of the current control word is `6`, use the previously processed control word as the current one for further processing. +* If the type of the current control word is `5`, tiles shall be skipped + * Read a 5-bit value from the bit-stream and advance it by 5 bits. This is the `Skip Count` value. + * A `Skip Count` of `0x1F` skips the remainder of the current tile row + * Any other value skips the given amount of columns, additionally to the current one. A value of 0 skips only the current tile. +* Any type in the range of `0` to `4` colours the current tile. See below. +* Next loop iteration + +> Type `7` is never encountered. According to the documentation of TSSHP, this has the same meaning as `6`: use previous control word. + +##### Encoder hints + +* Using the previous control word (type `6`) is not allowed for the first tile of a row. It can't repeat the last operation from the previous row. +* Usage of "transparency" byte `0x00` is only processed if at the first index of a palette lookup. See below. + +##### Colouring a tile + +To colour a tile, the `Parameter` value of the current control word is used, as well as the `mask-stream` and the `palette lookup list`. The 16 pixel of a tile are coloured from left to right, top to bottom. + +The palette value for a pixel is determined by an index into a small `lookup array` of palette indices. Depending on the type of the control word, this lookup array is either a made up one, or a segment of the `palette lookup list`. The length of a `lookup array` is always a power of 2 and has possible lengths of 2, 4, 8 or 16. + +The corresponding index values into such lookup arrays have bit sizes of 1, 2, 3 or 4 bits. The 16 index values for a tile are packed into a `mask integer` of 2, 4, 6 or 8 bytes. Depending on the type of the control word, this `mask integer` is made up or read from the `mask-stream`. + +> For example, a lookup array of 4 palette values requires an index size of 2 bits, resulting in a mask integer of 4 byte. +> +> For pixel coordinates given in x,y: Mask integer bits 0-1 colour pixel 0,0; mask integer bits 2-3 colour pixel 1,0; ... +> mask integer bits 28-29 colour pixel 2,3 and mask integer bits 30-31 colour pixel 3,3. + + +| Type | Lookup Array | Mask Integer | +|:----:|--------------------------------------------------------|-------------------------| +| 0 | `[ Parameter >> 0 & 0xFF, Parameter >> 8 & 0xFF ]` | Static `0xAAAA` | +| 1 | `[ Parameter >> 0 & 0xFF, Parameter >> 8 & 0xFF ]` | 2 byte from mask-stream | +| 2 | Parameter is start index into list, 4 entries | 4 byte from mask-stream | +| 3 | Parameter is start index into list, 8 entries | 6 byte from mask-stream | +| 4 | Parameter is start index into list, 16 entries | 8 byte from mask-stream | + +If the lookup array index is `0`, and the entry ix `0x00`, use the color from the previous frame. diff --git a/modding/ss-specs/other/Music.md b/modding/ss-specs/other/Music.md new file mode 100644 index 0000000..be61bcd --- /dev/null +++ b/modding/ss-specs/other/Music.md @@ -0,0 +1,7 @@ +## Music Files + +> For the time being, music files are not covered. For further information, follow these resources: +> +> SSPLAYER: The random song generator. Available from these links (including source): +> * http://www.vgmpf.com/Wiki/index.php?title=System_Shock_Random_Song_Generator +> * http://wenchy.net/old/ssplayer.html diff --git a/registry.env.example b/registry.env.example index 1610b64..7a4dbfc 100644 --- a/registry.env.example +++ b/registry.env.example @@ -7,3 +7,8 @@ REGISTRY=cr.ladkau.de REGISTRY_IMAGE=cr.ladkau.de/questshock/builder REGISTRY_USER= REGISTRY_PASSWORD= + +# Optional: modding/hacked-build-image.sh's own image name, read from this +# same file. REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD above are shared with +# it for pushing. +HACKED_REGISTRY_IMAGE=cr.ladkau.de/questshock/hacked-builder

W)+b8>g`B<5;i%pG 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame09_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame09_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..5c56118c13eba6e4d28fb5ec14b1c19342bb9b0b GIT binary patch literal 4213 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame09_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame09_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..2aa6deae3afc3ed81f4a3b077b13000747e8c95a GIT binary patch literal 12132 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 HAA}-Sqrh#|^;hc%^a^JW< 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame10_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame10_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..37d7558ee09a4d568bb9c5d8e0b6bf5c382cb57e GIT binary patch literal 11870 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame11_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame11_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..de61cd4b0c3f253988a6fd5a6d70c96788bc44bb GIT binary patch literal 12106 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 Crjg44 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame12_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame12_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..f0710ec186d96fa3da73445dad00f2ed9dafd635 GIT binary patch literal 4166 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 O>fC!ODNPem+Vel`@fD^3 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame12_maskstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame12_maskstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..a0d27b0018927573e7511f001e8acd0105da3c62 GIT binary patch literal 12070 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 literal 0 HcmV?d00001 diff --git a/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame13_bitstream.bin b/modding/hacked/ss1/content/movie/internal/compression/_testdata/deth/scene00/frame13_bitstream.bin new file mode 100644 index 0000000000000000000000000000000000000000..d0febe9e6a82d5c8b3a16b124a38b8a09bc637b4 GIT binary patch literal 4275 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