Adding modding folder with a hacked build.

This commit is contained in:
ml
2026-09-26 08:53:08 +02:00
parent cdb960dc71
commit c91080aba7
755 changed files with 50424 additions and 0 deletions
+19
View File
@@ -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
}
+31
View File
@@ -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"
)
+8
View File
@@ -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)
}
+23
View File
@@ -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")
}
}
+208
View File
@@ -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()
}
+48
View File
@@ -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)
}
@@ -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)
}
}
+170
View File
@@ -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...)
}
+53
View File
@@ -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,
}
}
@@ -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))
}
+478
View File
@@ -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] })
}
+324
View File
@@ -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)
}
}
}
}
})
}
+154
View File
@@ -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)
}
}
@@ -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)
})
}
+235
View File
@@ -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),
})
}
}
+41
View File
@@ -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)
}
@@ -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
}
@@ -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
}
@@ -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
}
+4
View File
@@ -0,0 +1,4 @@
/*
Package world and its subpackages contain everything a typical simulated world requires.
*/
package world
+61
View File
@@ -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
}
+127
View File
@@ -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
}
@@ -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+">")
}
}
@@ -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
)
+161
View File
@@ -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},
}
+197
View File
@@ -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},
}
+3
View File
@@ -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