1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Libraries;
7
8use App\Exceptions\InvariantException;
9use App\Models\Beatmap;
10use App\Models\Score\Best\Model as ScoreBest;
11use Illuminate\Contracts\Filesystem\Filesystem;
12
13class ReplayFile
14{
15 const DEFAULT_VERSION = 20151228;
16
17 public function __construct(private ScoreBest $score)
18 {
19 }
20
21 public function delete(): void
22 {
23 $this->storage()->delete($this->path());
24 }
25
26 /**
27 * Generates the end chunk for replay files.
28 *
29 * @return string Binary string of the chunk.
30 */
31 public function endChunk()
32 {
33 return pack('q', $this->score->score_id);
34 }
35
36 public function get(): ?string
37 {
38 $body = $this->storage()->get($this->path());
39
40 return $body === null
41 ? null
42 : $this->headerChunk()
43 .pack('i', strlen($body))
44 .$body
45 .$this->endChunk();
46 }
47
48 public function getVersion()
49 {
50 return $this->score->replayViewCount?->version ?? static::DEFAULT_VERSION;
51 }
52
53 /**
54 * Generates the header chunk for replay files.
55 *
56 * @return string Binary string of the chunk.
57 */
58 public function headerChunk(): string
59 {
60 $score = $this->score;
61 $beatmap = $score->beatmap()->withTrashed()->first();
62
63 if ($beatmap === null) {
64 throw new InvariantException('score is missing beatmap');
65 }
66
67 $mode = Beatmap::MODES[$score->getMode()];
68 $user = $score->user;
69
70 if ($user === null) {
71 throw new InvariantException('score is missing user');
72 }
73
74 $md5 = md5("{$score->maxcombo}osu{$user->username}{$beatmap->checksum}{$score->score}{$score->rank}");
75 $ticks = $score->date->timestamp * 10000000 + 621355968000000000; // Conversion to dotnet DateTime.Ticks.
76
77 // easier debugging with array and implode instead of plain string concatenation.
78 $components = [
79 pack('c', $mode),
80 pack('i', $this->getVersion()),
81 pack_str($beatmap->checksum),
82 pack_str($user->username),
83 pack_str($md5),
84 pack('S', $score->count300),
85 pack('S', $score->count100),
86 pack('S', $score->count50),
87 pack('S', $score->countgeki),
88 pack('S', $score->countkatu),
89 pack('S', $score->countmiss),
90 pack('i', $score->score),
91 pack('S', $score->maxcombo),
92 pack('c', $score->perfect),
93 pack('i', $score->getAttributes()['enabled_mods']),
94 pack_str(''), // outputs 0b00 from site, 00 if exported from game client.
95 pack('q', $ticks),
96 ];
97
98 return implode('', $components);
99 }
100
101 public function put(string $content): void
102 {
103 $this->storage()->put($this->path(), $content);
104 }
105
106 private function path(): string
107 {
108 return (string) $this->score->getKey();
109 }
110
111 private function storage(): Filesystem
112 {
113 $disk = "replays.{$this->score->getMode()}.{$GLOBALS['cfg']['osu']['score_replays']['storage']}";
114
115 return \Storage::disk($disk);
116 }
117}