Skip to content

Commit bd3e0b2

Browse files
committed
add tool for sprite conversion
1 parent ac55ba5 commit bd3e0b2

File tree

8 files changed

+466
-5
lines changed

8 files changed

+466
-5
lines changed

.idea/.idea.DarkSeedTools/.idea/vcs.xml

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

DarkSeedTools.sln

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
Microsoft Visual Studio Solution File, Format Version 12.00
33
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TosText", "TosText\TosText.csproj", "{13F1F8FB-B359-4963-9C04-54BB3C4D68B4}"
44
EndProject
5+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TosSprites", "TosSprites\TosSprites.csproj", "{2BB8F15A-8041-496E-AD3C-9E681B99CF30}"
6+
EndProject
57
Global
68
GlobalSection(SolutionConfigurationPlatforms) = preSolution
79
Debug|Any CPU = Debug|Any CPU
@@ -12,5 +14,9 @@ Global
1214
{13F1F8FB-B359-4963-9C04-54BB3C4D68B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
1315
{13F1F8FB-B359-4963-9C04-54BB3C4D68B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
1416
{13F1F8FB-B359-4963-9C04-54BB3C4D68B4}.Release|Any CPU.Build.0 = Release|Any CPU
17+
{2BB8F15A-8041-496E-AD3C-9E681B99CF30}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
18+
{2BB8F15A-8041-496E-AD3C-9E681B99CF30}.Debug|Any CPU.Build.0 = Debug|Any CPU
19+
{2BB8F15A-8041-496E-AD3C-9E681B99CF30}.Release|Any CPU.ActiveCfg = Release|Any CPU
20+
{2BB8F15A-8041-496E-AD3C-9E681B99CF30}.Release|Any CPU.Build.0 = Release|Any CPU
1521
EndGlobalSection
1622
EndGlobal

DarkSeedTools.sln.DotSettings.user

Lines changed: 0 additions & 2 deletions
This file was deleted.

README.md

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Reads and writes TOSTEXT.BIN.
99
#### Extract texts
1010

1111
Usage:
12-
`TosText.exe extract`
12+
`TosText.exe extract -i "C:\DARKSEED\TOSTEXT.BIN" -o "C:\DARKSEED\TOSTEXT.TXT"`
1313

1414
```
1515
@@ -27,10 +27,9 @@ Usage:
2727
#### Rebuild TOSTEXT.BIN
2828

2929
Usage:
30-
`TosText.exe rebuild`
30+
`TosText.exe rebuild -i "C:\DARKSEED\TOSTEXT.TXT" -o "C:\DARKSEED\TOSTEXT.BIN"`
3131

3232
```
33-
3433
-i, --in Required. Path to input (txt) file
3534
3635
-o, --out Required. Path to output (bin) file
@@ -40,4 +39,39 @@ Usage:
4039
--help Display this help screen.
4140
4241
--version Display version information.
42+
```
43+
44+
45+
## TosSprite
46+
47+
Reads and writes sprites from and to .NSP files.
48+
49+
#### Extract sprites
50+
51+
Usage:
52+
`TosSprites.exe extract -i "C:\DARKSEED\CPLAYER.NSP" -o "C:\DARKSEED\out"`
53+
54+
```
55+
-i, --in Required. Path to the input file
56+
57+
-o, --out Required. Path where the gif files are stored
58+
59+
--help Display this help screen.
60+
61+
--version Display version information.
62+
```
63+
64+
#### Rebuild TOSTEXT.BIN
65+
66+
If the sprites are stored at CPLAYER_0.gif, CPLAYER_1.gif and so on, the prefix would be "CPLAYER".
67+
68+
Usage:
69+
`TosSprites.exe rebuild -i "C:\DARKSEED\out" -o "C:\DARKSEED\CPLAYER.NSP" -o "CPLAYER"`
70+
71+
```
72+
-i, --in Required. Path to input files
73+
74+
-p, --prefix Required. Input filename prefix
75+
76+
-o, --out Required. Path to output file
4377
```

TosSprites/Converter.cs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
using SixLabors.ImageSharp;
2+
using SixLabors.ImageSharp.PixelFormats;
3+
using SixLabors.ImageSharp.Formats.Gif;
4+
using SixLabors.ImageSharp.Processing.Processors.Quantization;
5+
6+
namespace TosSprites;
7+
8+
public static class Converter
9+
{
10+
private static readonly GifEncoder Encoder;
11+
12+
private static readonly Dictionary<(byte R, byte G, byte B), byte> ColorToIndexMap = new();
13+
private static readonly Rgba32[] PaletteColors = new Rgba32[16];
14+
15+
static Converter()
16+
{
17+
for (byte i = 0; i < 15; i++)
18+
{
19+
var gray = (byte)(i * 255 / 14);
20+
PaletteColors[i] = new Rgba32(gray, gray, gray, 255);
21+
ColorToIndexMap[(gray, gray, gray)] = i;
22+
}
23+
24+
// Transparent (0xFF in original file)
25+
PaletteColors[15] = new Rgba32(0, 0, 0, 0);
26+
27+
var colors = new Color[16];
28+
for (byte i = 0; i < 16; i++)
29+
{
30+
colors[i] = new Color(PaletteColors[i]);
31+
}
32+
33+
Encoder = new GifEncoder
34+
{
35+
ColorTableMode = GifColorTableMode.Local,
36+
Quantizer = new PaletteQuantizer(
37+
new ReadOnlyMemory<Color>(colors),
38+
new QuantizerOptions { Dither = null })
39+
};
40+
}
41+
42+
public static void ToGif(string outputPath, Sprite sprite)
43+
{
44+
// Special case for empty sprites
45+
if (sprite.IsEmptySprite)
46+
{
47+
SaveEmptySprite(outputPath);
48+
return;
49+
}
50+
51+
// We only care for visible pixels
52+
using var image = new Image<Rgba32>(sprite.Width, sprite.Height);
53+
54+
for (var y = 0; y < sprite.Height; y++)
55+
{
56+
for (var x = 0; x < sprite.Width; x++)
57+
{
58+
var pixelValue = sprite.GetPixel(x, y);
59+
60+
if (pixelValue == Sprite.TransparentPixel)
61+
{
62+
image[x, y] = new Rgba32(0, 0, 0, 0);
63+
}
64+
else if (pixelValue < PaletteColors.Length)
65+
{
66+
image[x, y] = PaletteColors[pixelValue];
67+
}
68+
else
69+
{
70+
image[x, y] = new Rgba32(0, 0, 0, 255);
71+
}
72+
}
73+
}
74+
75+
image.Save(outputPath, Encoder);
76+
}
77+
78+
private static void SaveEmptySprite(string outputPath)
79+
{
80+
using var emptyImage = new Image<Rgba32>(1, 1);
81+
emptyImage[0, 0] = new Rgba32(0, 0, 0, 0); // Transparent
82+
emptyImage.Save(outputPath);
83+
}
84+
85+
public static Sprite FromGif(string inputPath)
86+
{
87+
using var image = Image.Load<Rgba32>(inputPath);
88+
89+
if (image is { Width: 1, Height: 1 } && image[0, 0].A == 0)
90+
{
91+
return Sprite.CreateEmpty();
92+
}
93+
94+
var width = (ushort)image.Width;
95+
var height = (ushort)image.Height;
96+
var pitch = (ushort)(width + (width & 1));
97+
98+
var sprite = new Sprite(width, height, pitch);
99+
100+
for (var y = 0; y < height; y++)
101+
{
102+
for (var x = 0; x < width; x++)
103+
{
104+
var color = image[x, y];
105+
106+
if (color.A == 0)
107+
{
108+
sprite.SetPixel(x, y, Sprite.TransparentPixel);
109+
}
110+
else
111+
{
112+
var colorIndex = GetExactColorIndex(color, inputPath, x, y);
113+
sprite.SetPixel(x, y, colorIndex);
114+
}
115+
}
116+
117+
if (width != pitch)
118+
{
119+
// For odd widths, explicitly set the padding pixel to transparent
120+
sprite.SetPixel(width, y, Sprite.TransparentPixel);
121+
}
122+
}
123+
124+
return sprite;
125+
}
126+
127+
private static byte GetExactColorIndex(Rgba32 color, string filePath, int x, int y)
128+
{
129+
if (color.A == 0) return Sprite.TransparentPixel;
130+
131+
if (ColorToIndexMap.TryGetValue((color.R, color.G, color.B), out var index))
132+
{
133+
return index;
134+
}
135+
136+
throw new Exception($"No exact color match found for RGB({color.R},{color.G},{color.B}) at position ({x},{y}) in file {Path.GetFileName(filePath)}");
137+
}
138+
}

TosSprites/Program.cs

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
using CommandLine;
2+
using NaturalSort.Extension;
3+
4+
namespace TosSprites;
5+
6+
internal abstract class Program
7+
{
8+
// Extract operation class
9+
[Verb("convert", HelpText = "Convert a .NSP file to .gif files")]
10+
public class ConvertOptions
11+
{
12+
[Option('i', "in", Required = true, HelpText = "Path to the input file")]
13+
public string InputPath { get; set; }
14+
15+
[Option('o', "out", Required = true, HelpText = "Path where the gif files are stored")]
16+
public string OutputPath { get; set; }
17+
}
18+
19+
// Rebuild operation class
20+
[Verb("rebuild", HelpText = "Rebuild .gif files to an .NSP file")]
21+
public class RebuildOptions
22+
{
23+
[Option('i', "in", Required = true, HelpText = "Path to input files")]
24+
public string InputPath { get; set; }
25+
26+
[Option('p', "prefix", Required = true, HelpText = "Input filename prefix")]
27+
public string InputFilePrefix { get; set; }
28+
29+
[Option('o', "out", Required = true, HelpText = "Path to output file")]
30+
public string OutputFile { get; set; }
31+
}
32+
33+
private static int Main(string[] args)
34+
{
35+
return Parser.Default.ParseArguments<ConvertOptions, RebuildOptions>(args)
36+
.MapResult(
37+
(ConvertOptions opts) => RunExtract(opts),
38+
(RebuildOptions opts) => RunRebuild(opts),
39+
_ => 1);
40+
}
41+
42+
private static int RunExtract(ConvertOptions opts)
43+
{
44+
if (!File.Exists(opts.InputPath))
45+
{
46+
Console.Error.WriteLine($"Input file not found: {opts.InputPath}");
47+
return -1;
48+
}
49+
50+
if (!Directory.Exists(opts.OutputPath))
51+
{
52+
Directory.CreateDirectory(opts.OutputPath);
53+
}
54+
55+
// Create the base filename for sprites (e.g., CPLAYER_NSP)
56+
var baseFileName = Path.GetFileNameWithoutExtension(opts.InputPath);
57+
58+
Console.WriteLine($"Processing {opts.InputPath}...");
59+
60+
try
61+
{
62+
using var file = File.OpenRead(opts.InputPath);
63+
using var reader = new BinaryReader(file);
64+
65+
var spriteCount = file.Length > 0 ? 0xC0 / 2 : 0;
66+
var sprites = new Sprite[spriteCount];
67+
68+
for (var i = 0; i < sprites.Length; i++)
69+
{
70+
int width = reader.ReadByte();
71+
int height = reader.ReadByte();
72+
var pitch = width + (width & 1); // Add padding if width is odd
73+
sprites[i] = new Sprite((ushort)width, (ushort)height, (ushort)pitch);
74+
}
75+
76+
for (var i = 0; i < sprites.Length; i++)
77+
{
78+
sprites[i].Load(reader);
79+
var gifPath = Path.Combine(opts.OutputPath, $"{baseFileName}_{i}.gif");
80+
Converter.ToGif(gifPath, sprites[i]);
81+
}
82+
83+
Console.WriteLine($"Converted {sprites.Length} sprites to GIF in {opts.OutputPath}");
84+
return 1;
85+
}
86+
catch (Exception ex)
87+
{
88+
Console.Error.WriteLine($"Error: {ex.Message}");
89+
Console.Error.WriteLine(ex.StackTrace);
90+
return -1;
91+
}
92+
}
93+
94+
private static int RunRebuild(RebuildOptions opts)
95+
{
96+
if (!Directory.Exists(opts.InputPath))
97+
{
98+
Console.Error.WriteLine($"Input directory not found: {opts.InputPath}");
99+
return -1;
100+
}
101+
102+
var sprites = Directory
103+
.EnumerateFiles(opts.InputPath, $"{opts.InputFilePrefix}_*.gif", SearchOption.TopDirectoryOnly)
104+
.OrderBy(x => x, StringComparison.OrdinalIgnoreCase.WithNaturalSort())
105+
.ToDictionary(file => file, Converter.FromGif);
106+
107+
if (sprites.Count == 0)
108+
{
109+
Console.Error.WriteLine($"No input files found at {opts.InputPath}");
110+
return -1;
111+
}
112+
113+
if (File.Exists(opts.OutputFile))
114+
{
115+
File.Delete(opts.OutputFile);
116+
}
117+
118+
// Step 3: Create a new file by loading the GIFs back
119+
using var outFile = File.Create(opts.OutputFile);
120+
using var writer = new BinaryWriter(outFile);
121+
122+
foreach (var sprite in sprites.Select(kvp => kvp.Value))
123+
{
124+
writer.Write((byte)sprite.Width);
125+
writer.Write((byte)sprite.Height);
126+
}
127+
128+
foreach (var kvp in sprites)
129+
{
130+
try
131+
{
132+
kvp.Value.Save(writer);
133+
}
134+
catch (Exception ex)
135+
{
136+
Console.WriteLine($"Error processing sprite {kvp.Key}: {ex.Message}");
137+
throw;
138+
}
139+
}
140+
141+
Console.WriteLine($"Successfully created {opts.OutputFile}");
142+
return 1;
143+
}
144+
}

0 commit comments

Comments
 (0)